@aexol/spectral 0.9.204 → 0.9.206

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.
@@ -1 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/agent/agents.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AACrD,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;AAC/C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;IAChD,QAAQ,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,eAAO,MAAM,aAAa,EAAE,WAAW,EAyCtC,CAAC;AA0DF,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAO3E;AACD;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC,yEAAyE;IACzE,YAAY,EAAE,IAAI,CAAC;IACnB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG;IACF,YAAY,EAAE,KAAK,CAAC;IACpB,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CA6EnC;AAkBD,wBAAgB,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,UAAU,EACjB,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,oBAAoB,CAmBtB;AACD,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,EACX,KAAK,GAAE,UAAmB,EAC1B,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,WAAW,GAAG,IAAI,CAepB;AACD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACvC,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,WAAW,GAAG,IAAI,CAgBpB;AACD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,YAAY,EAAE,IAAI,CAChB,WAAW,EACX,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAC7D,GAAG,IAAI,GACP,MAAM,GAAG,SAAS,CAQpB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,IAAI,CACT,WAAW,EACX,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAC7D,GAAG,IAAI,GACP,MAAM,CAgBR"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/agent/agents.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AACrD,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;AAC/C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;IAChD,QAAQ,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,eAAO,MAAM,aAAa,EAAE,WAAW,EAmDtC,CAAC;AA0DF,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAO3E;AACD;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC,yEAAyE;IACzE,YAAY,EAAE,IAAI,CAAC;IACnB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG;IACF,YAAY,EAAE,KAAK,CAAC;IACpB,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CA6EnC;AAkBD,wBAAgB,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,UAAU,EACjB,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,oBAAoB,CAmBtB;AACD,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,EACX,KAAK,GAAE,UAAmB,EAC1B,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,WAAW,GAAG,IAAI,CAepB;AACD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACvC,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,WAAW,GAAG,IAAI,CAgBpB;AACD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,YAAY,EAAE,IAAI,CAChB,WAAW,EACX,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAC7D,GAAG,IAAI,GACP,MAAM,GAAG,SAAS,CAQpB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,IAAI,CACT,WAAW,EACX,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAC7D,GAAG,IAAI,GACP,MAAM,CAgBR"}
@@ -6,9 +6,9 @@ import { classifyGraphqlError, createBackendClient, graphqlErrorMessage, scalars
6
6
  export const SYSTEM_AGENTS = [
7
7
  {
8
8
  name: "ghost",
9
- description: "Built-in orchestrator primary agent that delegates work to researcher, executor, and reviewer subagents.",
9
+ description: "Built-in orchestrator primary agent that delegates work to researcher, executor, reviewer, and fork subagents.",
10
10
  mode: "primary",
11
- systemPrompt: "You are ghost, the built-in system orchestrator. Plan the work, then delegate by calling the `subagent` tool with one of your subagents, then integrate the results into a coherent outcome.\n\nYour subagents (call them via the `subagent` tool, e.g. subagent(agent=\"researcher\", task=\"...\")):\n- `researcher` — investigate the codebase, read code and documentation, and report precise, cited findings without making changes.\n- `executor` — implement the requested changes minimally and safely, run verification, and report exactly what changed with evidence.\n- `reviewer` — review proposed or implemented changes for correctness, edge cases, security, and consistency with the codebase.\n\nDelegate the actual work to the appropriate subagent instead of doing it yourself; use `researcher` for research/investigation, `executor` for implementation, and `reviewer` for review.",
11
+ systemPrompt: "You are ghost, the built-in system orchestrator. Plan the work, then delegate by calling the `subagent` tool with one of your subagents, then integrate the results into a coherent outcome.\n\nYour subagents (call them via the `subagent` tool, e.g. subagent(agent=\"researcher\", task=\"...\")):\n- `researcher` — investigate the codebase, read code and documentation, and report precise, cited findings without making changes.\n- `executor` — implement the requested changes minimally and safely, run verification, and report exactly what changed with evidence.\n- `reviewer` — review proposed or implemented changes for correctness, edge cases, security, and consistency with the codebase.\n- `fork` — a generalist worker with the same broad capabilities as the main thread; give it a self-contained task and it carries it end-to-end.\n\nTry to delegate most of the work to your subagents: use `researcher` for research/investigation, `executor` for implementation, `reviewer` for review, and `fork` for generalist tasks that don't fit the other three.",
12
12
  source: "system",
13
13
  filePath: "system:ghost",
14
14
  },
@@ -36,6 +36,14 @@ export const SYSTEM_AGENTS = [
36
36
  source: "system",
37
37
  filePath: "system:reviewer",
38
38
  },
39
+ {
40
+ name: "fork",
41
+ description: "Built-in system generalist subagent — works like the main thread on a delegated task.",
42
+ mode: "subagent",
43
+ systemPrompt: "You are the fork subagent for ghost — a generalist with the same broad capabilities as the main thread. Own the delegated task end-to-end: investigate what you need, do the work, verify the result, and report the outcome with evidence.",
44
+ source: "system",
45
+ filePath: "system:fork",
46
+ },
39
47
  ];
40
48
  function loadAgentsFromDir(dir, source) {
41
49
  const agents = [];
@@ -3,5 +3,34 @@ export interface NpxResolution {
3
3
  extraArgs: string[];
4
4
  isJs: boolean;
5
5
  }
6
- export declare function resolveNpxBinary(command: string, args: string[]): Promise<NpxResolution | null>;
6
+ export interface ResolveOptions {
7
+ /** Override for the global node_modules root (tests / custom environments). */
8
+ globalRoot?: string | null;
9
+ /** Override for the npm cache dir (tests / custom environments). */
10
+ npmCacheDir?: string | null;
11
+ /** Override for the registry "latest version" lookup (tests / offline). */
12
+ latestVersionFetcher?: (packageName: string) => Promise<string | null>;
13
+ /** Override for the npx-cache population step (tests). */
14
+ forceInstall?: () => Promise<void>;
15
+ }
16
+ interface ParsedInvocation {
17
+ packageSpec: string;
18
+ binName?: string;
19
+ extraArgs: string[];
20
+ }
21
+ export declare function resolveNpxBinary(command: string, args: string[], options?: ResolveOptions): Promise<NpxResolution | null>;
22
+ export declare function parseNpxArgs(args: string[]): ParsedInvocation | null;
23
+ export declare function parseNpmExecArgs(args: string[]): ParsedInvocation | null;
24
+ export declare function extractPackageName(spec: string): string | null;
25
+ /**
26
+ * A "floating" spec is one without a version pin: bare name, `@latest` or `@*`.
27
+ * Only floating specs may resolve from the global tree and only they get the
28
+ * registry freshness check — a pinned spec must match its exact version.
29
+ */
30
+ export declare function isFloatingSpec(spec: string): boolean;
31
+ /** Reset per-process memoizations (used by tests). */
32
+ export declare function resetNpxResolverCachesForTests(): void;
33
+ /** Semver-ish comparison; release beats prerelease, identifiers compare numerically when both numeric. */
34
+ export declare function compareVersions(a: string, b: string): number;
35
+ export {};
7
36
  //# sourceMappingURL=npx-resolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"npx-resolver.d.ts","sourceRoot":"","sources":["../../src/mcp/npx-resolver.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACf;AAQD,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAgC/B"}
1
+ {"version":3,"file":"npx-resolver.d.ts","sourceRoot":"","sources":["../../src/mcp/npx-resolver.ts"],"names":[],"mappings":"AAqCA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,2EAA2E;IAC3E,oBAAoB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvE,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,UAAU,gBAAgB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CA0D/B;AAMD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,gBAAgB,GAAG,IAAI,CAkDpE;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,gBAAgB,GAAG,IAAI,CAmCxE;AAqKD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc9D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAKpD;AAqED,sDAAsD;AACtD,wBAAgB,8BAA8B,IAAI,IAAI,CAGrD;AA+GD,0GAA0G;AAC1G,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAW5D"}
@@ -1,11 +1,26 @@
1
1
  // npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes
2
+ //
3
+ // Resolution order (mirrors `npm exec` semantics, minus the local project tree):
4
+ // 1. Global install (`<prefix>/lib/node_modules/<pkg>`) — beats the npx cache,
5
+ // so `npm i -g pkg@latest` takes effect on the next server (re)connect.
6
+ // Pinned specs (`pkg@1.2.3`) never resolve from global.
7
+ // 2. Own resolution cache (`~/.spectral/agent/mcp-npx-cache.json`, 24h TTL) —
8
+ // hot path, no registry traffic.
9
+ // 3. npx cache scan (`~/.npm/_npx/*`); floating specs (`pkg`, `pkg@latest`,
10
+ // `pkg@*`) get a registry freshness check once per TTL window and the
11
+ // cache is refreshed via `npm exec` when a newer version exists.
12
+ // 4. Slow path: force-populate the npx cache, then re-scan.
2
13
  import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
3
14
  import { join, dirname, extname, resolve, sep } from "node:path";
4
15
  import { getAgentPath } from "./agent-dir.js";
5
16
  import spawn from "cross-spawn";
6
17
  const CACHE_VERSION = 1;
7
18
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
8
- export async function resolveNpxBinary(command, args) {
19
+ const CACHE_PRUNE_MS = 30 * 24 * 60 * 60 * 1000;
20
+ const REGISTRY_CHECK_TIMEOUT_MS = 10_000;
21
+ const GLOBAL_ROOT_TIMEOUT_MS = 5_000;
22
+ const FORCE_CACHE_TIMEOUT_MS = 30_000;
23
+ export async function resolveNpxBinary(command, args, options = {}) {
9
24
  const parsed = command === "npx"
10
25
  ? parseNpxArgs(args)
11
26
  : command === "npm"
@@ -16,24 +31,52 @@ export async function resolveNpxBinary(command, args) {
16
31
  const cacheKey = JSON.stringify([command, ...args]);
17
32
  const cache = loadCache();
18
33
  const cached = cache?.entries?.[cacheKey];
34
+ // 1. Global install wins (npm exec semantics: global tree beats npx cache).
35
+ const global = resolveFromGlobal(parsed.packageSpec, parsed.binName, options.globalRoot);
36
+ if (global) {
37
+ if (!cached || cached.resolvedBin !== global.resolvedBin) {
38
+ saveCacheEntry(cacheKey, global);
39
+ }
40
+ return toResolution(global, parsed.extraArgs);
41
+ }
42
+ // 2. Own resolution cache (hot path — no registry traffic, TTL-governed).
19
43
  if (cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS && existsSync(cached.resolvedBin)) {
20
- return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs };
44
+ return toResolution(cached, parsed.extraArgs);
45
+ }
46
+ // 3. npx cache scan; floating specs get a registry freshness check on refresh.
47
+ const scanned = resolveFromNpmCache(parsed.packageSpec, parsed.binName, options.npmCacheDir);
48
+ if (scanned && isFloatingSpec(parsed.packageSpec) && scanned.packageVersion) {
49
+ const fetcher = options.latestVersionFetcher ?? fetchLatestVersion;
50
+ const packageName = extractPackageName(parsed.packageSpec) ?? parsed.packageSpec;
51
+ const latest = await fetcher(packageName);
52
+ if (latest && compareVersions(latest, scanned.packageVersion) > 0) {
53
+ const force = options.forceInstall ?? (() => forceNpxCache(parsed.packageSpec));
54
+ await force();
55
+ const refreshed = resolveFromNpmCache(parsed.packageSpec, parsed.binName, options.npmCacheDir);
56
+ if (refreshed) {
57
+ saveCacheEntry(cacheKey, refreshed);
58
+ return toResolution(refreshed, parsed.extraArgs);
59
+ }
60
+ }
21
61
  }
22
- const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
23
- if (resolved) {
24
- saveCacheEntry(cacheKey, resolved);
25
- return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs };
62
+ if (scanned) {
63
+ saveCacheEntry(cacheKey, scanned);
64
+ return toResolution(scanned, parsed.extraArgs);
26
65
  }
27
- // Slow path: force npx cache population
28
- await forceNpxCache(parsed.packageSpec);
29
- const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
66
+ // 4. Slow path: force npx cache population.
67
+ const force = options.forceInstall ?? (() => forceNpxCache(parsed.packageSpec));
68
+ await force();
69
+ const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName, options.npmCacheDir);
30
70
  if (resolvedAfterInstall) {
31
71
  saveCacheEntry(cacheKey, resolvedAfterInstall);
32
- return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs };
72
+ return toResolution(resolvedAfterInstall, parsed.extraArgs);
33
73
  }
34
74
  return null;
35
75
  }
36
- function parseNpxArgs(args) {
76
+ function toResolution(entry, extraArgs) {
77
+ return { binPath: entry.resolvedBin, extraArgs, isJs: entry.isJs };
78
+ }
79
+ export function parseNpxArgs(args) {
37
80
  const separatorIndex = args.indexOf("--");
38
81
  const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
39
82
  const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
@@ -87,7 +130,7 @@ function parseNpxArgs(args) {
87
130
  const extraArgs = positionals.slice(1).concat(after);
88
131
  return { packageSpec: packagePositional, extraArgs };
89
132
  }
90
- function parseNpmExecArgs(args) {
133
+ export function parseNpmExecArgs(args) {
91
134
  if (args[0] !== "exec")
92
135
  return null;
93
136
  const execArgs = args.slice(1);
@@ -128,8 +171,65 @@ function parseNpmExecArgs(args) {
128
171
  const extraArgs = after.slice(1);
129
172
  return { packageSpec, binName, extraArgs };
130
173
  }
131
- function resolveFromNpmCache(packageSpec, binName) {
132
- const cacheDir = getNpmCacheDir();
174
+ function pickBinEntry(binField, packageName, explicitBin) {
175
+ if (typeof binField === "string") {
176
+ return { name: defaultBinName(packageName), rel: binField };
177
+ }
178
+ for (const candidate of buildBinCandidates(packageName, explicitBin)) {
179
+ if (binField[candidate]) {
180
+ return { name: candidate, rel: binField[candidate] };
181
+ }
182
+ }
183
+ const firstEntry = Object.entries(binField)[0];
184
+ if (firstEntry) {
185
+ return { name: firstEntry[0], rel: firstEntry[1] };
186
+ }
187
+ return null;
188
+ }
189
+ /**
190
+ * Resolve the package from the npm global install tree. Global beats the npx
191
+ * cache, but only floating specs (`pkg`, `pkg@latest`, `pkg@*`) may resolve
192
+ * from global — a pinned spec must match its exact version.
193
+ */
194
+ function resolveFromGlobal(packageSpec, binName, globalRootOverride) {
195
+ if (!isFloatingSpec(packageSpec))
196
+ return null;
197
+ const packageName = extractPackageName(packageSpec);
198
+ if (!packageName)
199
+ return null;
200
+ const root = globalRootOverride ?? getGlobalRoot();
201
+ if (!root)
202
+ return null;
203
+ const pkgDir = join(root, ...packageName.split("/"));
204
+ const packageJsonPath = join(pkgDir, "package.json");
205
+ if (!existsSync(packageJsonPath))
206
+ return null;
207
+ let pkg = null;
208
+ try {
209
+ pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ const binField = pkg?.bin;
215
+ if (!binField)
216
+ return null;
217
+ const picked = pickBinEntry(binField, packageName, binName);
218
+ if (!picked)
219
+ return null;
220
+ const resolvedBin = resolve(pkgDir, picked.rel);
221
+ if (!existsSync(resolvedBin))
222
+ return null;
223
+ return {
224
+ resolvedBin,
225
+ resolvedAt: Date.now(),
226
+ packageVersion: pkg?.version,
227
+ isJs: detectJsBinary(resolvedBin),
228
+ source: "global",
229
+ };
230
+ }
231
+ function resolveFromNpmCache(packageSpec, binName, cacheDirOverride) {
232
+ const cacheDir = cacheDirOverride ?? getNpmCacheDir();
133
233
  if (!cacheDir)
134
234
  return null;
135
235
  const packageName = extractPackageName(packageSpec);
@@ -151,59 +251,36 @@ function resolveFromNpmCache(packageSpec, binName) {
151
251
  const binField = pkg?.bin;
152
252
  if (!binField)
153
253
  return null;
154
- const candidates = buildBinCandidates(packageName, binName);
155
- let chosenBinName;
156
- let binRel;
157
- if (typeof binField === "string") {
158
- chosenBinName = defaultBinName(packageName);
159
- binRel = binField;
160
- }
161
- else {
162
- for (const candidate of candidates) {
163
- if (binField[candidate]) {
164
- chosenBinName = candidate;
165
- binRel = binField[candidate];
166
- break;
167
- }
168
- }
169
- if (!binRel) {
170
- const firstEntry = Object.entries(binField)[0];
171
- if (firstEntry) {
172
- chosenBinName = firstEntry[0];
173
- binRel = firstEntry[1];
174
- }
175
- }
176
- }
177
- if (!binRel)
254
+ const picked = pickBinEntry(binField, packageName, binName);
255
+ if (!picked)
178
256
  return null;
179
257
  const nodeModulesDir = findNodeModulesDir(packageDir);
180
- const binLink = chosenBinName ? join(nodeModulesDir, ".bin", chosenBinName) : null;
181
- let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : "";
258
+ const binLink = join(nodeModulesDir, ".bin", picked.name);
259
+ let resolvedBin = existsSync(binLink) ? safeRealpath(binLink) : "";
182
260
  if (!resolvedBin) {
183
- resolvedBin = resolve(packageDir, binRel);
261
+ resolvedBin = resolve(packageDir, picked.rel);
184
262
  if (!existsSync(resolvedBin))
185
263
  return null;
186
264
  }
187
- const isJs = detectJsBinary(resolvedBin);
188
265
  return {
189
266
  resolvedBin,
190
267
  resolvedAt: Date.now(),
191
268
  packageVersion: pkg?.version,
192
- isJs,
269
+ isJs: detectJsBinary(resolvedBin),
270
+ source: "npx-cache",
193
271
  };
194
272
  }
195
- const FORCE_CACHE_TIMEOUT_MS = 30_000;
196
273
  async function forceNpxCache(packageSpec) {
197
274
  try {
198
- await new Promise((resolve, reject) => {
275
+ await new Promise((resolvePromise, rejectPromise) => {
199
276
  const proc = spawn("npm", ["exec", "--yes", "--package", packageSpec, "--", "node", "-e", "1"], { stdio: "ignore" });
200
277
  const timer = setTimeout(() => {
201
278
  proc.kill();
202
- reject(new Error("timeout"));
279
+ rejectPromise(new Error("timeout"));
203
280
  }, FORCE_CACHE_TIMEOUT_MS);
204
281
  timer.unref();
205
- proc.on("close", () => { clearTimeout(timer); resolve(); });
206
- proc.on("error", (err) => { clearTimeout(timer); reject(err); });
282
+ proc.on("close", () => { clearTimeout(timer); resolvePromise(); });
283
+ proc.on("error", (err) => { clearTimeout(timer); rejectPromise(err); });
207
284
  });
208
285
  }
209
286
  catch {
@@ -221,13 +298,15 @@ function buildBinCandidates(packageName, explicitBin) {
221
298
  candidates.push(namePart);
222
299
  if (scopePart && namePart)
223
300
  candidates.push(`${scopePart}-${namePart}`);
301
+ if (scopePart)
302
+ candidates.push(scopePart);
224
303
  }
225
304
  else {
226
305
  candidates.push(packageName);
227
306
  }
228
307
  return [...new Set(candidates.filter(Boolean))];
229
308
  }
230
- function extractPackageName(spec) {
309
+ export function extractPackageName(spec) {
231
310
  const trimmed = spec.trim();
232
311
  if (!trimmed)
233
312
  return null;
@@ -244,6 +323,18 @@ function extractPackageName(spec) {
244
323
  const atIndex = trimmed.indexOf("@");
245
324
  return atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed;
246
325
  }
326
+ /**
327
+ * A "floating" spec is one without a version pin: bare name, `@latest` or `@*`.
328
+ * Only floating specs may resolve from the global tree and only they get the
329
+ * registry freshness check — a pinned spec must match its exact version.
330
+ */
331
+ export function isFloatingSpec(spec) {
332
+ const name = extractPackageName(spec);
333
+ if (!name)
334
+ return false;
335
+ const trimmed = spec.trim();
336
+ return trimmed === name || trimmed === `${name}@latest` || trimmed === `${name}@*`;
337
+ }
247
338
  function defaultBinName(packageName) {
248
339
  if (packageName.startsWith("@")) {
249
340
  const parts = packageName.split("/");
@@ -265,7 +356,9 @@ function findCachedPackageDir(cacheDir, packageName) {
265
356
  const mtime = safeStatMtime(full);
266
357
  return { name: entry.name, mtime };
267
358
  })
268
- .sort((a, b) => b.mtime - a.mtime);
359
+ // Newest first; on equal mtimes fall back to a stable name order so
360
+ // resolution is deterministic.
361
+ .sort((a, b) => (b.mtime - a.mtime) || a.name.localeCompare(b.name));
269
362
  for (const entry of candidates) {
270
363
  const pkgDir = join(npxDir, entry.name, "node_modules", ...packagePathParts);
271
364
  if (existsSync(join(pkgDir, "package.json"))) {
@@ -303,6 +396,12 @@ function detectJsBinary(binPath) {
303
396
  }
304
397
  }
305
398
  let npmCacheDirCached;
399
+ let globalRootCached;
400
+ /** Reset per-process memoizations (used by tests). */
401
+ export function resetNpxResolverCachesForTests() {
402
+ npmCacheDirCached = undefined;
403
+ globalRootCached = undefined;
404
+ }
306
405
  function getNpmCacheDir() {
307
406
  if (npmCacheDirCached !== undefined)
308
407
  return npmCacheDirCached;
@@ -325,6 +424,155 @@ function getNpmCacheDir() {
325
424
  npmCacheDirCached = null;
326
425
  return null;
327
426
  }
427
+ /**
428
+ * Resolve the global node_modules root (e.g.
429
+ * `<prefix>/lib/node_modules` on unix, `<prefix>/node_modules` on Windows).
430
+ * Derived from the running node binary to avoid spawning npm on the hot path;
431
+ * falls back to `npm root -g` when the derived path does not exist.
432
+ */
433
+ function getGlobalRoot() {
434
+ if (globalRootCached !== undefined)
435
+ return globalRootCached;
436
+ const derived = deriveGlobalRoot();
437
+ if (derived && existsSync(derived)) {
438
+ globalRootCached = derived;
439
+ return globalRootCached;
440
+ }
441
+ globalRootCached = discoverGlobalRootViaNpm();
442
+ return globalRootCached;
443
+ }
444
+ function deriveGlobalRoot() {
445
+ const prefix = process.env.NPM_CONFIG_PREFIX?.trim() || process.env.npm_config_prefix?.trim() || "";
446
+ const base = prefix || dirname(dirname(process.execPath));
447
+ if (!base)
448
+ return null;
449
+ return join(base, process.platform === "win32" ? "node_modules" : join("lib", "node_modules"));
450
+ }
451
+ function discoverGlobalRootViaNpm() {
452
+ try {
453
+ const result = spawn.sync("npm", ["root", "-g"], {
454
+ encoding: "utf-8",
455
+ timeout: GLOBAL_ROOT_TIMEOUT_MS,
456
+ });
457
+ if (result.status === 0) {
458
+ const path = String(result.stdout).trim();
459
+ if (path)
460
+ return path;
461
+ }
462
+ }
463
+ catch {
464
+ // Ignore — global resolution simply stays unavailable.
465
+ }
466
+ return null;
467
+ }
468
+ /**
469
+ * Registry "latest" lookup for floating specs. Returns null on any failure
470
+ * (offline, timeout) so resolution degrades to the local state.
471
+ */
472
+ async function fetchLatestVersion(packageName) {
473
+ return new Promise((resolvePromise) => {
474
+ let settled = false;
475
+ let timer;
476
+ const done = (value) => {
477
+ if (settled)
478
+ return;
479
+ settled = true;
480
+ if (timer)
481
+ clearTimeout(timer);
482
+ resolvePromise(value);
483
+ };
484
+ try {
485
+ const proc = spawn("npm", ["view", packageName, "version", "--json"], {
486
+ stdio: ["ignore", "pipe", "ignore"],
487
+ });
488
+ let out = "";
489
+ timer = setTimeout(() => {
490
+ proc.kill();
491
+ done(null);
492
+ }, REGISTRY_CHECK_TIMEOUT_MS);
493
+ timer.unref();
494
+ proc.stdout?.on("data", (chunk) => {
495
+ out += chunk.toString("utf-8");
496
+ });
497
+ proc.on("close", () => done(parseLatestVersionOutput(out)));
498
+ proc.on("error", () => done(null));
499
+ }
500
+ catch {
501
+ done(null);
502
+ }
503
+ });
504
+ }
505
+ function parseLatestVersionOutput(out) {
506
+ const raw = out.trim();
507
+ if (!raw)
508
+ return null;
509
+ let value = raw;
510
+ try {
511
+ const parsed = JSON.parse(raw);
512
+ if (typeof parsed === "string")
513
+ value = parsed;
514
+ }
515
+ catch {
516
+ value = raw.replace(/^["']|["']$/g, "");
517
+ }
518
+ return /^v?\d+\.\d+\.\d+/.test(value) ? value : null;
519
+ }
520
+ /** Semver-ish comparison; release beats prerelease, identifiers compare numerically when both numeric. */
521
+ export function compareVersions(a, b) {
522
+ const pa = parseVersion(a);
523
+ const pb = parseVersion(b);
524
+ if (!pa || !pb) {
525
+ // Unparseable — fall back to lexicographic so the comparison stays total.
526
+ return a < b ? -1 : a > b ? 1 : 0;
527
+ }
528
+ for (const key of ["major", "minor", "patch"]) {
529
+ if (pa[key] !== pb[key])
530
+ return pa[key] < pb[key] ? -1 : 1;
531
+ }
532
+ return comparePrerelease(pa.prerelease, pb.prerelease);
533
+ }
534
+ function parseVersion(v) {
535
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(v.trim());
536
+ if (!m)
537
+ return null;
538
+ return {
539
+ major: Number(m[1]),
540
+ minor: Number(m[2]),
541
+ patch: Number(m[3]),
542
+ prerelease: m[4] ? m[4].split(".") : null,
543
+ };
544
+ }
545
+ function comparePrerelease(a, b) {
546
+ if (!a && !b)
547
+ return 0;
548
+ if (!a)
549
+ return 1;
550
+ if (!b)
551
+ return -1;
552
+ const len = Math.max(a.length, b.length);
553
+ for (let i = 0; i < len; i++) {
554
+ const ai = a[i];
555
+ const bi = b[i];
556
+ if (ai === undefined)
557
+ return -1;
558
+ if (bi === undefined)
559
+ return 1;
560
+ const an = /^\d+$/.test(ai);
561
+ const bn = /^\d+$/.test(bi);
562
+ if (an && bn) {
563
+ const diff = Number(ai) - Number(bi);
564
+ if (diff !== 0)
565
+ return diff < 0 ? -1 : 1;
566
+ }
567
+ else if (an !== bn) {
568
+ return an ? -1 : 1;
569
+ }
570
+ else if (ai !== bi) {
571
+ return ai < bi ? -1 : 1;
572
+ }
573
+ }
574
+ return 0;
575
+ }
328
576
  function getNpxCachePath() {
329
577
  return getAgentPath("mcp-npx-cache.json");
330
578
  }
@@ -350,7 +598,7 @@ function saveCacheEntry(key, entry) {
350
598
  const cachePath = getNpxCachePath();
351
599
  const dir = dirname(cachePath);
352
600
  mkdirSync(dir, { recursive: true });
353
- let merged = { version: CACHE_VERSION, entries: {} };
601
+ const merged = { version: CACHE_VERSION, entries: {} };
354
602
  try {
355
603
  if (existsSync(cachePath)) {
356
604
  const existing = JSON.parse(readFileSync(cachePath, "utf-8"));
@@ -362,6 +610,13 @@ function saveCacheEntry(key, entry) {
362
610
  catch {
363
611
  // Ignore parse errors
364
612
  }
613
+ // Prune entries that have not been re-resolved for 30 days.
614
+ const cutoff = Date.now() - CACHE_PRUNE_MS;
615
+ for (const [existingKey, existingEntry] of Object.entries(merged.entries)) {
616
+ if (existingEntry.resolvedAt < cutoff) {
617
+ delete merged.entries[existingKey];
618
+ }
619
+ }
365
620
  merged.entries[key] = entry;
366
621
  const tmpPath = `${cachePath}.${process.pid}.tmp`;
367
622
  writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.204",
3
+ "version": "0.9.206",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,