@ankhorage/doctor 0.13.2 → 0.14.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.
@@ -0,0 +1,64 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ type Capability,
6
+ isCapability,
7
+ normalizeCapability,
8
+ } from '@ankhorage/contracts/capabilities';
9
+
10
+ import { capabilityCatalogAstAsync } from './capabilityCatalogAst.js';
11
+ import { capabilityCatalogExports } from './capabilityCatalogExports.js';
12
+
13
+ const CAPABILITIES_SOURCE_PATH = 'src/capabilities/index.ts';
14
+
15
+ type CatalogResolution =
16
+ | { readonly capabilities: readonly Capability[]; readonly path: string; readonly reason: null }
17
+ | { readonly capabilities: null; readonly path: string; readonly reason: string }
18
+ | { readonly capabilities: null; readonly path: null; readonly reason: null };
19
+
20
+ /*** Read an opted-in package catalog without executing target package code. */
21
+ export async function readCapabilityCatalogAsync(
22
+ packageRoot: string,
23
+ packageExports: unknown,
24
+ ): Promise<CatalogResolution> {
25
+ const catalogPath = path.resolve(packageRoot, CAPABILITIES_SOURCE_PATH);
26
+ if (!(await isFileAsync(catalogPath))) return { capabilities: null, path: null, reason: null };
27
+ try {
28
+ capabilityCatalogExports(packageRoot, packageExports, catalogPath);
29
+ const values = await capabilityCatalogAstAsync(packageRoot, catalogPath, 'CAPABILITIES');
30
+ if (!Array.isArray(values))
31
+ throw new Error(`${CAPABILITIES_SOURCE_PATH} must export CAPABILITIES as an array.`);
32
+ const capabilities = values.map((value, index) => {
33
+ if (!isCapability(value))
34
+ throw new Error(`CAPABILITIES[${index}] is not a valid Capability.`);
35
+ return normalizeCapability(value);
36
+ });
37
+ assertUniqueCapabilityIds(capabilities);
38
+ return { capabilities, path: catalogPath, reason: null };
39
+ } catch (error) {
40
+ return {
41
+ capabilities: null,
42
+ path: catalogPath,
43
+ reason: error instanceof Error ? error.message : String(error),
44
+ };
45
+ }
46
+ }
47
+
48
+ /*** Check whether one candidate is a regular file. */
49
+ async function isFileAsync(candidate: string): Promise<boolean> {
50
+ try {
51
+ return (await fs.stat(candidate)).isFile();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /*** Reject duplicate identifiers in one canonical catalog. */
58
+ function assertUniqueCapabilityIds(capabilities: readonly Capability[]): void {
59
+ const ids = new Set<string>();
60
+ for (const capability of capabilities) {
61
+ if (ids.has(capability.id)) throw new Error(`Duplicate capability id "${capability.id}".`);
62
+ ids.add(capability.id);
63
+ }
64
+ }
@@ -0,0 +1,319 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import * as ts from 'typescript';
5
+
6
+ type StaticValue = string | number | boolean | null | readonly StaticValue[] | StaticRecord;
7
+ interface StaticRecord {
8
+ readonly [key: string]: StaticValue;
9
+ }
10
+
11
+ /*** Evaluate one named catalog export from the supported static TypeScript subset. */
12
+ export async function capabilityCatalogAstAsync(
13
+ packageRoot: string,
14
+ modulePath: string,
15
+ exportName: string,
16
+ ): Promise<unknown> {
17
+ return new StaticCatalogResolver(packageRoot).resolveAsync(modulePath, exportName);
18
+ }
19
+
20
+ /*** Resolve static local imports and expressions without loading target modules. */
21
+ class StaticCatalogResolver {
22
+ readonly #symbols = new Map<string, StaticValue>();
23
+ readonly #activeSymbols: string[] = [];
24
+
25
+ constructor(private readonly packageRoot: string) {}
26
+
27
+ /*** Resolve a named local export without importing or running its module. */
28
+ async resolveAsync(modulePath: string, exportName: string): Promise<StaticValue> {
29
+ const key = `${modulePath}:${exportName}`;
30
+ return this.resolveSymbolAsync(key, () => this.resolveUncachedAsync(modulePath, exportName));
31
+ }
32
+
33
+ /*** Resolve one local symbol while detecting recursive static references. */
34
+ async resolveSymbolAsync(
35
+ key: string,
36
+ resolveAsync: () => Promise<StaticValue>,
37
+ ): Promise<StaticValue> {
38
+ const cached = this.#symbols.get(key);
39
+ if (cached !== undefined) return cached;
40
+ const cycleStart = this.#activeSymbols.indexOf(key);
41
+ if (cycleStart >= 0)
42
+ throw new Error(
43
+ `Cyclic static capability reference: ${[...this.#activeSymbols.slice(cycleStart), key].join(' -> ')}.`,
44
+ );
45
+ this.#activeSymbols.push(key);
46
+ try {
47
+ const value = await resolveAsync();
48
+ this.#symbols.set(key, value);
49
+ return value;
50
+ } finally {
51
+ this.#activeSymbols.pop();
52
+ }
53
+ }
54
+
55
+ /*** Find and evaluate the declaration owning one exported static value. */
56
+ async resolveUncachedAsync(modulePath: string, exportName: string): Promise<StaticValue> {
57
+ const source = ts.createSourceFile(
58
+ modulePath,
59
+ await fs.readFile(modulePath, 'utf8'),
60
+ ts.ScriptTarget.ESNext,
61
+ true,
62
+ );
63
+ const declaration = source.statements
64
+ .filter((statement): statement is ts.VariableStatement => ts.isVariableStatement(statement))
65
+ .filter(hasExportModifier)
66
+ .filter((statement) => isConstDeclaration(statement.declarationList))
67
+ .flatMap((statement) => statement.declarationList.declarations)
68
+ .find((value) => ts.isIdentifier(value.name) && value.name.text === exportName);
69
+ if (declaration?.initializer === undefined)
70
+ throw new Error(`Unable to find static export ${exportName} in ${modulePath}.`);
71
+ return this.evaluateAsync(
72
+ declaration.initializer,
73
+ new StaticCatalogScope(this, source, modulePath, this.packageRoot),
74
+ );
75
+ }
76
+
77
+ /*** Evaluate an allowed static expression. */
78
+ async evaluateAsync(
79
+ expression: ts.Expression,
80
+ environment: StaticCatalogScope,
81
+ ): Promise<StaticValue> {
82
+ const node = unwrapExpression(expression);
83
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
84
+ if (ts.isNumericLiteral(node)) return Number(node.text);
85
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
86
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
87
+ if (node.kind === ts.SyntaxKind.NullKeyword) return null;
88
+ if (ts.isIdentifier(node)) return environment.resolveAsync(node.text);
89
+ if (ts.isArrayLiteralExpression(node)) return this.arrayAsync(node, environment);
90
+ if (ts.isObjectLiteralExpression(node)) return this.objectAsync(node, environment);
91
+ if (ts.isTemplateExpression(node)) return this.templateAsync(node, environment);
92
+ if (ts.isCallExpression(node)) return this.mapAsync(node, environment);
93
+ throw new Error(`Unsupported static capability expression: ${node.getText()}.`);
94
+ }
95
+
96
+ /*** Evaluate a static array literal. */
97
+ arrayAsync(
98
+ node: ts.ArrayLiteralExpression,
99
+ environment: StaticCatalogScope,
100
+ ): Promise<readonly StaticValue[]> {
101
+ return this.evaluateElementsAsync(node.elements, environment);
102
+ }
103
+
104
+ /*** Evaluate static object properties and descriptor spreads. */
105
+ async objectAsync(
106
+ node: ts.ObjectLiteralExpression,
107
+ environment: StaticCatalogScope,
108
+ ): Promise<StaticRecord> {
109
+ const result: Record<string, StaticValue> = {};
110
+ for (const property of node.properties) {
111
+ if (ts.isSpreadAssignment(property)) {
112
+ const value = await this.evaluateAsync(property.expression, environment);
113
+ if (!isStaticRecord(value))
114
+ throw new Error('Capability descriptor spreads must be static objects.');
115
+ Object.assign(result, value);
116
+ } else if (
117
+ ts.isPropertyAssignment(property) &&
118
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
119
+ )
120
+ result[property.name.text] = await this.evaluateAsync(property.initializer, environment);
121
+ else if (ts.isShorthandPropertyAssignment(property))
122
+ result[property.name.text] = await environment.resolveAsync(property.name.text);
123
+ else throw new Error(`Unsupported static capability property: ${property.getText()}.`);
124
+ }
125
+ return result;
126
+ }
127
+
128
+ /*** Evaluate a template literal whose substitutions are scalar static values. */
129
+ async templateAsync(
130
+ node: ts.TemplateExpression,
131
+ environment: StaticCatalogScope,
132
+ ): Promise<string> {
133
+ let value = node.head.text;
134
+ for (const span of node.templateSpans) {
135
+ const interpolation = await this.evaluateAsync(span.expression, environment);
136
+ if (typeof interpolation === 'object' && interpolation !== null)
137
+ throw new Error('Capability templates may interpolate only static scalar values.');
138
+ value += `${interpolation}${span.literal.text}`;
139
+ }
140
+ return value;
141
+ }
142
+
143
+ /*** Evaluate Array.map with a single static arrow-function parameter. */
144
+ async mapAsync(
145
+ node: ts.CallExpression,
146
+ environment: StaticCatalogScope,
147
+ ): Promise<readonly StaticValue[]> {
148
+ if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'map')
149
+ throw new Error(`Unsupported static capability call: ${node.getText()}.`);
150
+ const values = await this.evaluateAsync(node.expression.expression, environment);
151
+ const callback = node.arguments.at(0);
152
+ if (!isStaticArray(values) || callback === undefined || !ts.isArrowFunction(callback))
153
+ throw new Error(`Unsupported static capability map: ${node.getText()}.`);
154
+ const parameter = callback.parameters.at(0);
155
+ const { body } = callback;
156
+ if (parameter === undefined || !ts.isIdentifier(parameter.name) || !ts.isExpression(body))
157
+ throw new Error(`Unsupported static capability map callback: ${callback.getText()}.`);
158
+ const parameterName = parameter.name.text;
159
+ const result: StaticValue[] = [];
160
+ for (const value of values)
161
+ result.push(await this.evaluateAsync(body, environment.withValue(parameterName, value)));
162
+ return result;
163
+ }
164
+
165
+ /*** Evaluate static array elements in source order. */
166
+ async evaluateElementsAsync(
167
+ elements: ts.NodeArray<ts.Expression | ts.SpreadElement>,
168
+ environment: StaticCatalogScope,
169
+ ): Promise<readonly StaticValue[]> {
170
+ const result: StaticValue[] = [];
171
+ for (const element of elements) {
172
+ if (ts.isSpreadElement(element)) throw new Error('Catalog array spreads are not supported.');
173
+ result.push(await this.evaluateAsync(element, environment));
174
+ }
175
+ return result;
176
+ }
177
+ }
178
+
179
+ /*** Resolve only static symbols reached from the requested catalog expression. */
180
+ class StaticCatalogScope {
181
+ constructor(
182
+ private readonly resolver: StaticCatalogResolver,
183
+ private readonly source: ts.SourceFile,
184
+ private readonly modulePath: string,
185
+ private readonly packageRoot: string,
186
+ private readonly values: ReadonlyMap<string, StaticValue> = new Map(),
187
+ ) {}
188
+
189
+ /*** Resolve a local constant or named local import when it is referenced. */
190
+ async resolveAsync(name: string): Promise<StaticValue> {
191
+ const value = this.values.get(name);
192
+ if (value !== undefined) return value;
193
+ const declaration = findStaticDeclaration(this.source, name);
194
+ const initializer = declaration?.initializer;
195
+ if (initializer !== undefined)
196
+ return this.resolver.resolveSymbolAsync(`${this.modulePath}:${name}`, () =>
197
+ this.resolver.evaluateAsync(initializer, this),
198
+ );
199
+ const imported = findImportBinding(this.source, name);
200
+ if (imported === undefined) throw new Error(`Unsupported dynamic capability value: ${name}.`);
201
+ if (!ts.isStringLiteral(imported.moduleSpecifier))
202
+ throw new Error('Capability catalogs support only named local value imports.');
203
+ const bindings = imported.importClause?.namedBindings;
204
+ if (imported.importClause?.isTypeOnly || bindings === undefined)
205
+ throw new Error(`Unsupported dynamic capability value: ${name}.`);
206
+ if (!ts.isNamedImports(bindings))
207
+ throw new Error('Capability catalogs support only named local value imports.');
208
+ const binding = bindings.elements.find(
209
+ (element) => !element.isTypeOnly && element.name.text === name,
210
+ );
211
+ if (binding === undefined) throw new Error(`Unsupported dynamic capability value: ${name}.`);
212
+ const importPath = await resolveImportPathAsync(
213
+ this.packageRoot,
214
+ this.modulePath,
215
+ imported.moduleSpecifier.text,
216
+ );
217
+ return this.resolver.resolveAsync(importPath, binding.propertyName?.text ?? binding.name.text);
218
+ }
219
+
220
+ /*** Add a static callback parameter without evaluating unrelated module symbols. */
221
+ withValue(name: string, value: StaticValue): StaticCatalogScope {
222
+ return new StaticCatalogScope(
223
+ this.resolver,
224
+ this.source,
225
+ this.modulePath,
226
+ this.packageRoot,
227
+ new Map([...this.values, [name, value]]),
228
+ );
229
+ }
230
+ }
231
+
232
+ /*** Find the static top-level declaration that owns a local identifier. */
233
+ function findStaticDeclaration(
234
+ source: ts.SourceFile,
235
+ name: string,
236
+ ): ts.VariableDeclaration | undefined {
237
+ return source.statements
238
+ .filter((statement): statement is ts.VariableStatement => ts.isVariableStatement(statement))
239
+ .filter((statement) => isConstDeclaration(statement.declarationList))
240
+ .flatMap((statement) => statement.declarationList.declarations)
241
+ .find((declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === name);
242
+ }
243
+
244
+ /*** Find the import declaration that introduces a referenced local identifier. */
245
+ function findImportBinding(source: ts.SourceFile, name: string): ts.ImportDeclaration | undefined {
246
+ return source.statements
247
+ .filter((statement): statement is ts.ImportDeclaration => ts.isImportDeclaration(statement))
248
+ .find((statement) => {
249
+ const clause = statement.importClause;
250
+ return (
251
+ clause?.name?.text === name ||
252
+ (clause?.namedBindings !== undefined &&
253
+ ts.isNamedImports(clause.namedBindings) &&
254
+ clause.namedBindings.elements.some((element) => element.name.text === name))
255
+ );
256
+ });
257
+ }
258
+
259
+ /*** Determine whether a declaration list is immutable static catalog input. */
260
+ function isConstDeclaration(list: ts.VariableDeclarationList): boolean {
261
+ return (list.flags & ts.NodeFlags.Const) !== 0;
262
+ }
263
+
264
+ /*** Resolve a local import to one source file inside the inspected package. */
265
+ async function resolveImportPathAsync(
266
+ packageRoot: string,
267
+ fromPath: string,
268
+ specifier: string,
269
+ ): Promise<string> {
270
+ if (!specifier.startsWith('.'))
271
+ throw new Error('Capability catalogs may import only local static metadata.');
272
+ const candidate = path.resolve(path.dirname(fromPath), specifier);
273
+ if (!isInsidePackage(packageRoot, candidate) && candidate !== packageRoot)
274
+ throw new Error('Capability catalogs may not import outside their package.');
275
+ for (const extension of ['', '.ts', '.tsx', '.js', '.mjs', '/index.ts'])
276
+ if (await isFileAsync(`${candidate}${extension}`)) return `${candidate}${extension}`;
277
+ throw new Error(`Unable to resolve static capability import ${specifier}.`);
278
+ }
279
+
280
+ /*** Unwrap TypeScript syntax which does not alter a runtime value. */
281
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
282
+ return ts.isAsExpression(expression) ||
283
+ ts.isSatisfiesExpression(expression) ||
284
+ ts.isParenthesizedExpression(expression)
285
+ ? unwrapExpression(expression.expression)
286
+ : expression;
287
+ }
288
+
289
+ /*** Narrow a static value to a descriptor-shaped record. */
290
+ function isStaticRecord(value: StaticValue): value is StaticRecord {
291
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
292
+ }
293
+
294
+ /*** Narrow a static value to an array. */
295
+ function isStaticArray(value: StaticValue): value is readonly StaticValue[] {
296
+ return Array.isArray(value);
297
+ }
298
+
299
+ /*** Detect an exported variable statement. */
300
+ function hasExportModifier(statement: ts.VariableStatement): boolean {
301
+ return (
302
+ statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
303
+ );
304
+ }
305
+
306
+ /*** Check whether a resolved path remains within the inspected package root. */
307
+ function isInsidePackage(packageRoot: string, candidate: string): boolean {
308
+ const relativePath = path.relative(packageRoot, candidate);
309
+ return relativePath !== '' && !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..';
310
+ }
311
+
312
+ /*** Check whether one candidate is a regular file. */
313
+ async function isFileAsync(candidate: string): Promise<boolean> {
314
+ try {
315
+ return (await fs.stat(candidate)).isFile();
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
@@ -0,0 +1,53 @@
1
+ import path from 'node:path';
2
+
3
+ /*** Resolve and validate the public export for an authored capability catalog. */
4
+ export function capabilityCatalogExports(
5
+ packageRoot: string,
6
+ packageExports: unknown,
7
+ catalogPath: string,
8
+ ): void {
9
+ const target = readCapabilityExportTarget(
10
+ isRecord(packageExports) ? packageExports['./capabilities'] : undefined,
11
+ );
12
+ if (target === null)
13
+ throw new Error('Package exports must define "./capabilities" for its canonical catalog.');
14
+ if (!target.startsWith('./') || !isInsidePackage(packageRoot, path.resolve(packageRoot, target)))
15
+ throw new Error('The public "./capabilities" export must resolve within its package.');
16
+ if (
17
+ !capabilitySourceCandidatesForExport(target).some(
18
+ (candidate) => path.resolve(packageRoot, candidate) === catalogPath,
19
+ )
20
+ )
21
+ throw new Error(
22
+ 'The public "./capabilities" export must resolve to src/capabilities/index.ts.',
23
+ );
24
+ }
25
+
26
+ /*** Select one concrete target from a Node package export condition object. */
27
+ function readCapabilityExportTarget(value: unknown): string | null {
28
+ if (typeof value === 'string') return value;
29
+ if (!isRecord(value)) return null;
30
+ for (const candidate of [value.import, value.default, value.types]) {
31
+ const target = readCapabilityExportTarget(candidate);
32
+ if (target !== null) return target;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /*** Map a conventional dist export to authored source candidates. */
38
+ function capabilitySourceCandidatesForExport(target: string): readonly string[] {
39
+ if (!target.startsWith('./dist/') || !target.endsWith('.js')) return [target];
40
+ const stem = target.slice('./dist/'.length, -'.js'.length);
41
+ return [`./src/${stem}.ts`, `./src/${stem}.tsx`, `./src/${stem}.js`, `./src/${stem}.mjs`];
42
+ }
43
+
44
+ /*** Check whether a resolved target remains inside the inspected package. */
45
+ function isInsidePackage(packageRoot: string, candidate: string): boolean {
46
+ const relativePath = path.relative(packageRoot, candidate);
47
+ return relativePath !== '' && !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..';
48
+ }
49
+
50
+ /*** Narrow an unknown value to a plain record. */
51
+ function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
53
+ }
@@ -51,9 +51,13 @@ export type DoctorRuleId =
51
51
  | 'manifest.secret-store.valid-shape'
52
52
  | 'manifest.settings.auth-flow.removed'
53
53
  | 'package.ankh.capabilities.match-provider'
54
+ | 'package.ankh.capabilities.catalog.valid'
55
+ | 'package.ankh.capabilities.match-catalog'
56
+ | 'package.ankh.capabilities.unique'
54
57
  | 'package.ankh.present.valid-shape'
55
58
  | 'package.ankh.provider-path.required'
56
59
  | 'package.ankh.required-for-provider'
60
+ | 'package.ankh.required-for-capability-catalog'
57
61
  | 'package.cli.export.required'
58
62
  | 'package.cli.command-path.required'
59
63
  | 'package.cli.root-file.disallowed'