vouchington-tooling 0.37.1 → 0.37.2

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
@@ -89,6 +89,22 @@ vouchington post-review
89
89
  vouchington stage-review-payload optional|required <source> <destination>
90
90
  ```
91
91
 
92
+ ### Running `allocate-browser-safe-ports` without an install
93
+
94
+ The allocator is a standalone Python script that needs only the Python 3 standard library: no
95
+ Node, no `pnpm install`. Run it from the extracted published tarball:
96
+
97
+ ```bash
98
+ npm pack vouchington-tooling@<version> # or download the registry .tgz
99
+ mkdir extracted && tar -xzf vouchington-tooling-<version>.tgz -C extracted
100
+ python3 extracted/package/scripts/allocate-browser-safe-ports.py <count> [--policy ...] [--forbidden-ports ...]
101
+ ```
102
+
103
+ `pnpm dlx` and `npx` resolve the package's whole dependency tree, which can fail under
104
+ `minimumReleaseAge` (`ERR_PNPM_NO_MATURE_MATCHING_VERSION`) when a lockstep-published dependency
105
+ straddles the cutoff. CI jobs without an install should fetch the tarball instead. Tests enforce
106
+ that the script stays stdlib-only and reads only its two sibling JSON files.
107
+
92
108
  For persistent `pnpm-install`, v5 metadata tracks structural inputs separately from the
93
109
  `--install-scripts` policy. A warm scripts-enabled tree can therefore toggle
94
110
  `true → false → true` without forced reconciliation; a tree first installed with scripts disabled
@@ -216,6 +232,7 @@ import {
216
232
  installFakeGit,
217
233
  runNamedChecks,
218
234
  } from 'vouchington-tooling/shared-context'
235
+ import { createSourceModuleGraph } from 'vouchington-tooling/source-module-graph'
219
236
  import {
220
237
  decodeSelectedFiles,
221
238
  writeSelectedFilesOutput,
@@ -657,6 +674,18 @@ TypeScript and route rows from Markdown. Callers configure declaration names and
657
674
  runbook headings, then compare the returned data using their own route policy.
658
675
  See the [API contract](docs/basic-auth-doc-sync.md).
659
676
 
677
+ ## TypeScript source graph
678
+
679
+ `vouchington-tooling/source-module-graph` exports `createSourceModuleGraph`. It accepts the
680
+ `modules` report from released no-mistakes
681
+ `analyzeTypeScriptModules({ root, files })`, plus the caller's root, ordered source extensions,
682
+ aliases, and file inventory. It resolves local imports and reexport owners without reading or
683
+ parsing source again. `runtimeImports(file)` uses binding references rather than declaration
684
+ presence, and `reachableFrom(entrypoints, { runtimeOnly: true })` follows used runtime imports,
685
+ reexports, and literal dynamic/require loads. Missing or incomplete facts for a reached file
686
+ throw; external specifiers resolve to `null`. The default file inventory limit is 10,000
687
+ files and can be reduced with `maxFiles`.
688
+
660
689
  ## SQL view declarations
661
690
 
662
691
  `vouchington-tooling/sql-ast` exports `extractViewDeclarations(sql)` and the
@@ -0,0 +1,4 @@
1
+ import type { TypeScriptModuleFacts } from 'no-mistakes';
2
+ import { type SourceGraphPaths } from './source-graph-resolve.mts';
3
+ import type { SourceExportOwner } from './source-graph.mts';
4
+ export declare function createExportOwnerResolver(paths: SourceGraphPaths, factsFor: (file: string) => TypeScriptModuleFacts, maxFiles: number): (file: string, exportName: string) => SourceExportOwner | null;
@@ -0,0 +1,67 @@
1
+ import { relativeFile, resolveSourcePath } from "./source-graph-resolve.mjs";
2
+ export function createExportOwnerResolver(paths, factsFor, maxFiles) {
3
+ return (file, exportName) => {
4
+ const start = relativeFile(paths.root, file);
5
+ if (!start || !paths.files.has(start))
6
+ return null;
7
+ const queue = [{ file: start, exportName }];
8
+ const seen = new Set();
9
+ const owners = new Map();
10
+ let unresolved = false;
11
+ for (let index = 0; index < queue.length; index++) {
12
+ const current = queue[index];
13
+ const key = `${current.file}\0${current.exportName}`;
14
+ if (seen.has(key))
15
+ continue;
16
+ seen.add(key);
17
+ if (seen.size > maxFiles)
18
+ throw new Error('Source graph file limit exceeded');
19
+ const facts = factsFor(current.file);
20
+ const direct = facts.exports.filter((item) => !item.typeOnly && item.exported === current.exportName);
21
+ const matches = direct.length > 0
22
+ ? direct
23
+ : current.exportName === 'default'
24
+ ? [] // ESM export-star never reexports default.
25
+ : facts.exports.filter((item) => !item.typeOnly && item.local === '*' && item.exported === '*');
26
+ for (const item of matches) {
27
+ if (item.specifier) {
28
+ const target = resolveSourcePath(paths, current.file, item.specifier);
29
+ if (!target) {
30
+ unresolved = true;
31
+ }
32
+ else if (item.local === '*' && item.exported !== '*') {
33
+ factsFor(target);
34
+ owners.set(`${target}\0*`, { file: target, exportName: '*' });
35
+ }
36
+ else {
37
+ queue.push({
38
+ file: target,
39
+ exportName: item.local === '*' ? current.exportName : item.local,
40
+ });
41
+ }
42
+ continue;
43
+ }
44
+ const imported = facts.imports.flatMap((entry) => entry.bindings
45
+ .filter((binding) => !binding.typeOnly && binding.local === item.local)
46
+ .map((binding) => ({ entry, binding })))[0];
47
+ if (imported) {
48
+ const target = resolveSourcePath(paths, current.file, imported.entry.specifier);
49
+ if (!target) {
50
+ unresolved = true;
51
+ }
52
+ else if (imported.binding.kind === 'namespace') {
53
+ factsFor(target);
54
+ owners.set(`${target}\0*`, { file: target, exportName: '*' });
55
+ }
56
+ else {
57
+ queue.push({ file: target, exportName: imported.binding.imported });
58
+ }
59
+ continue;
60
+ }
61
+ const owner = { file: current.file, exportName: item.exported };
62
+ owners.set(`${owner.file}\0${owner.exportName}`, owner);
63
+ }
64
+ }
65
+ return !unresolved && owners.size === 1 ? [...owners.values()][0] : null;
66
+ };
67
+ }
@@ -0,0 +1,8 @@
1
+ export type SourceGraphPaths = {
2
+ root: string;
3
+ extensions: readonly string[];
4
+ aliases: Readonly<Record<string, string>>;
5
+ files: ReadonlySet<string>;
6
+ };
7
+ export declare function relativeFile(root: string, file: string): string | null;
8
+ export declare function resolveSourcePath(paths: SourceGraphPaths, fromFile: string, specifier: string): string | null;
@@ -0,0 +1,60 @@
1
+ import { isAbsolute, posix, relative, win32 } from 'node:path';
2
+ export function relativeFile(root, file) {
3
+ const value = isAbsolute(file) ? relative(root, file) : file;
4
+ if (win32.isAbsolute(value) || /^[A-Za-z]:/u.test(value))
5
+ return null;
6
+ const normalized = posix.normalize(value.replaceAll('\\', '/'));
7
+ return normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized)
8
+ ? null
9
+ : normalized;
10
+ }
11
+ export function resolveSourcePath(paths, fromFile, specifier) {
12
+ const normalizedFrom = relativeFile(paths.root, fromFile);
13
+ if (!normalizedFrom || !paths.files.has(normalizedFrom))
14
+ return null;
15
+ let target = null;
16
+ if (specifier.startsWith('.')) {
17
+ target = posix.join(posix.dirname(normalizedFrom), specifier);
18
+ }
19
+ else {
20
+ const alias = Object.keys(paths.aliases)
21
+ .filter((prefix) => specifier.startsWith(prefix))
22
+ .sort((a, b) => b.length - a.length)[0];
23
+ if (alias)
24
+ target = posix.join(paths.aliases[alias], specifier.slice(alias.length));
25
+ }
26
+ if (!target)
27
+ return null;
28
+ const base = relativeFile(paths.root, target);
29
+ if (!base)
30
+ return null;
31
+ const runtimeExtension = posix.extname(base);
32
+ const mappedExtensions = {
33
+ '.js': ['.ts', '.tsx'],
34
+ '.jsx': ['.tsx'],
35
+ '.mjs': ['.mts'],
36
+ '.cjs': ['.cts'],
37
+ };
38
+ const mapped = mappedExtensions[runtimeExtension];
39
+ if (mapped) {
40
+ const stem = base.slice(0, -runtimeExtension.length);
41
+ for (const suffix of paths.extensions) {
42
+ if (!mapped.includes(suffix))
43
+ continue;
44
+ const candidate = `${stem}${suffix}`;
45
+ if (paths.files.has(candidate))
46
+ return candidate;
47
+ }
48
+ }
49
+ for (const suffix of paths.extensions) {
50
+ const candidate = `${base}${suffix}`;
51
+ if (paths.files.has(candidate))
52
+ return candidate;
53
+ }
54
+ for (const suffix of paths.extensions) {
55
+ const candidate = posix.join(base, `index${suffix}`);
56
+ if (paths.files.has(candidate))
57
+ return candidate;
58
+ }
59
+ return paths.files.has(base) ? base : null;
60
+ }
@@ -0,0 +1,24 @@
1
+ import type { TypeScriptModuleImport, TypeScriptModulesReport } from 'no-mistakes';
2
+ export type SourceModuleGraphOptions = {
3
+ root: string;
4
+ extensions: readonly string[];
5
+ aliases?: Readonly<Record<string, string>>;
6
+ files: readonly string[];
7
+ facts: TypeScriptModulesReport;
8
+ /** Caps the supplied file inventory and every traversal. */
9
+ maxFiles?: number;
10
+ };
11
+ export type SourceExportOwner = {
12
+ file: string;
13
+ exportName: string;
14
+ };
15
+ export type SourceModuleGraph = {
16
+ resolveSource(fromFile: string, specifier: string): string | null;
17
+ runtimeImports(file: string): TypeScriptModuleImport[];
18
+ reachableFrom(entrypoints: readonly string[], options?: {
19
+ runtimeOnly?: boolean;
20
+ }): string[];
21
+ resolveExportOwner(file: string, exportName: string): SourceExportOwner | null;
22
+ };
23
+ /** Resolve only caller-supplied files; syntax and binding facts come from no-mistakes. */
24
+ export declare function createSourceModuleGraph(options: SourceModuleGraphOptions): SourceModuleGraph;
@@ -0,0 +1,86 @@
1
+ import { resolve } from 'node:path';
2
+ import { createExportOwnerResolver } from "./source-graph-owner.mjs";
3
+ import { relativeFile, resolveSourcePath } from "./source-graph-resolve.mjs";
4
+ /** Resolve only caller-supplied files; syntax and binding facts come from no-mistakes. */
5
+ export function createSourceModuleGraph(options) {
6
+ const root = resolve(options.root);
7
+ const maxFiles = options.maxFiles ?? 10_000;
8
+ if (!Number.isSafeInteger(maxFiles) || maxFiles < 1 || options.files.length > maxFiles) {
9
+ throw new Error('Source graph file limit exceeded');
10
+ }
11
+ const fileSet = new Set();
12
+ for (const file of options.files) {
13
+ const normalized = relativeFile(root, file);
14
+ if (!normalized)
15
+ throw new Error(`Source graph file escapes root: ${file}`);
16
+ fileSet.add(normalized);
17
+ }
18
+ const paths = {
19
+ root,
20
+ extensions: options.extensions,
21
+ aliases: options.aliases ?? {},
22
+ files: fileSet,
23
+ };
24
+ const modules = new Map();
25
+ for (const module of options.facts.modules) {
26
+ const file = relativeFile(root, module.fileName);
27
+ if (!file || !fileSet.has(file) || modules.has(file)) {
28
+ throw new Error(`Unexpected source module facts: ${module.fileName}`);
29
+ }
30
+ modules.set(file, module);
31
+ }
32
+ function factsFor(file) {
33
+ const normalized = relativeFile(root, file);
34
+ const facts = normalized && fileSet.has(normalized) ? modules.get(normalized) : undefined;
35
+ if (!facts || !facts.complete)
36
+ throw new Error(`Incomplete source module facts: ${file}`);
37
+ return facts;
38
+ }
39
+ function runtimeImports(file) {
40
+ const facts = factsFor(file);
41
+ return facts.imports.flatMap((entry) => {
42
+ if (entry.typeOnly)
43
+ return [];
44
+ if (entry.bindings.length === 0)
45
+ return [entry]; // Side-effect import.
46
+ const bindings = entry.bindings.filter((imported) => {
47
+ if (imported.typeOnly)
48
+ return false;
49
+ const binding = facts.bindings.find((item) => item.id === imported.bindingId);
50
+ if (!binding)
51
+ throw new Error(`Missing import binding facts: ${file}`);
52
+ return binding.references.some((reference) => reference.runtime && !reference.typeOnly);
53
+ });
54
+ return bindings.length > 0 ? [{ ...entry, bindings }] : [];
55
+ });
56
+ }
57
+ function reachableFrom(entrypoints, traversal = {}) {
58
+ const queue = entrypoints.map((file) => relativeFile(root, file));
59
+ const seen = new Set();
60
+ for (let index = 0; index < queue.length; index++) {
61
+ const file = queue[index];
62
+ if (!file || !fileSet.has(file) || seen.has(file))
63
+ continue;
64
+ seen.add(file);
65
+ const facts = factsFor(file);
66
+ const imports = traversal.runtimeOnly ? runtimeImports(file) : facts.imports;
67
+ const specifiers = [
68
+ ...imports.map((item) => item.specifier),
69
+ ...facts.exports.flatMap((item) => item.specifier && (!traversal.runtimeOnly || !item.typeOnly) ? [item.specifier] : []),
70
+ ...facts.loads.map((item) => item.specifier),
71
+ ];
72
+ for (const specifier of specifiers) {
73
+ const resolved = resolveSourcePath(paths, file, specifier);
74
+ if (resolved && !seen.has(resolved))
75
+ queue.push(resolved);
76
+ }
77
+ }
78
+ return [...seen].sort();
79
+ }
80
+ return {
81
+ resolveSource: (fromFile, specifier) => resolveSourcePath(paths, fromFile, specifier),
82
+ runtimeImports,
83
+ reachableFrom,
84
+ resolveExportOwner: createExportOwnerResolver(paths, factsFor, maxFiles),
85
+ };
86
+ }
@@ -0,0 +1,2 @@
1
+ export { createSourceModuleGraph } from '../shared-context/source-graph.mts';
2
+ export type { SourceExportOwner, SourceModuleGraph, SourceModuleGraphOptions, } from '../shared-context/source-graph.mts';
@@ -0,0 +1 @@
1
+ export { createSourceModuleGraph } from "../shared-context/source-graph.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.37.1",
3
+ "version": "0.37.2",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -136,6 +136,11 @@
136
136
  "import": "./dist/shared-context/index.mjs",
137
137
  "default": "./dist/shared-context/index.mjs"
138
138
  },
139
+ "./source-module-graph": {
140
+ "types": "./dist/source-module-graph/index.d.mts",
141
+ "import": "./dist/source-module-graph/index.mjs",
142
+ "default": "./dist/source-module-graph/index.mjs"
143
+ },
139
144
  "./gha-selected-files": {
140
145
  "types": "./dist/gha-selected-files/index.d.mts",
141
146
  "import": "./dist/gha-selected-files/index.mjs",
@@ -376,12 +381,14 @@
376
381
  "@types/picomatch": "^4.0.3",
377
382
  "@types/spdx-expression-parse": "4.0.0",
378
383
  "agent-blackboard": "^0.6.0",
384
+ "no-mistakes": "0.78.0",
379
385
  "smol-toml": "1.9.0",
380
386
  "typescript5": "npm:typescript@5.9.3"
381
387
  },
382
388
  "peerDependencies": {
383
389
  "@modelcontextprotocol/sdk": "^1.31.0",
384
390
  "agent-blackboard": "^0.6.0",
391
+ "no-mistakes": "^0.78.0",
385
392
  "typescript": ">=5",
386
393
  "vitest": ">=5"
387
394
  },
@@ -392,6 +399,9 @@
392
399
  "@modelcontextprotocol/sdk": {
393
400
  "optional": true
394
401
  },
402
+ "no-mistakes": {
403
+ "optional": true
404
+ },
395
405
  "typescript": {
396
406
  "optional": true
397
407
  },
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env python3
2
+ # Must stay stdlib-only and self-contained (reads only the sibling fetch-forbidden-ports.json
3
+ # and runner-port-policy.json): CI runs it from the extracted published tarball with no install.
4
+ # Enforced by allocate-browser-safe-ports-no-install.test.mts.
2
5
  from __future__ import annotations
3
6
 
4
7
  import argparse