cognitive-modules-cli 2.2.10 → 2.2.12

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,16 @@
1
+ import type { ExecutionPolicy, JsonSchemaMode, StructuredOutputPreference } from './types.js';
2
+ export type EffectiveValidation = {
3
+ validateInput: boolean;
4
+ validateOutput: boolean;
5
+ reason?: string | null;
6
+ };
7
+ export type EffectiveStructured = {
8
+ requested: StructuredOutputPreference;
9
+ applied: JsonSchemaMode | 'off';
10
+ reason?: string | null;
11
+ };
12
+ export declare function compactReason(reason: string | null | undefined, maxLen?: number): string | null;
13
+ export declare function formatPolicySummaryLine(policy: ExecutionPolicy, effectiveValidation: EffectiveValidation, effectiveStructured: EffectiveStructured, extras?: {
14
+ enableRepair?: boolean;
15
+ requireV22?: boolean;
16
+ }): string;
@@ -0,0 +1,33 @@
1
+ function oneLine(s) {
2
+ return s.replace(/\s+/g, ' ').trim();
3
+ }
4
+ export function compactReason(reason, maxLen = 90) {
5
+ if (!reason)
6
+ return null;
7
+ const s = oneLine(reason);
8
+ if (s.length <= maxLen)
9
+ return s;
10
+ return `${s.slice(0, Math.max(0, maxLen - 1))}…`;
11
+ }
12
+ function fmtOnOff(v) {
13
+ return v ? 'on' : 'off';
14
+ }
15
+ export function formatPolicySummaryLine(policy, effectiveValidation, effectiveStructured, extras) {
16
+ const validateMode = policy.validate;
17
+ const vReason = compactReason(effectiveValidation.reason ?? null);
18
+ const sReason = compactReason(effectiveStructured.reason ?? null);
19
+ const parts = [];
20
+ parts.push(`profile=${policy.profile}`);
21
+ parts.push(`validate=${validateMode}(in:${fmtOnOff(effectiveValidation.validateInput)} out:${fmtOnOff(effectiveValidation.validateOutput)})`);
22
+ if (vReason)
23
+ parts.push(`validate_reason="${vReason}"`);
24
+ const structuredArrow = effectiveStructured.requested === 'auto' ? '->' : ':';
25
+ parts.push(`structured=${effectiveStructured.requested}${structuredArrow}${effectiveStructured.applied}`);
26
+ if (sReason)
27
+ parts.push(`structured_reason="${sReason}"`);
28
+ parts.push(`audit=${fmtOnOff(Boolean(policy.audit))}`);
29
+ if (typeof extras?.enableRepair === 'boolean')
30
+ parts.push(`repair=${fmtOnOff(extras.enableRepair)}`);
31
+ parts.push(`requireV22=${fmtOnOff(Boolean(extras?.requireV22 ?? policy.requireV22))}`);
32
+ return `Policy: ${parts.join(' | ')}`;
33
+ }
package/dist/profile.d.ts CHANGED
@@ -4,5 +4,6 @@ export interface ResolvePolicyInput {
4
4
  validate?: string | null;
5
5
  noValidate?: boolean;
6
6
  audit?: boolean;
7
+ structured?: string | null;
7
8
  }
8
9
  export declare function resolveExecutionPolicy(input: ResolvePolicyInput): ExecutionPolicy;
package/dist/profile.js CHANGED
@@ -1,14 +1,14 @@
1
- function normalizeProfile(raw) {
1
+ function parseProfile(raw) {
2
2
  const v = (raw ?? '').trim().toLowerCase();
3
3
  if (v === 'core')
4
- return 'core';
5
- if (v === 'default' || v === '')
6
- return 'default';
7
- if (v === 'strict')
8
- return 'strict';
4
+ return { profile: 'core' };
5
+ if (v === 'standard' || v === '' || v === 'default')
6
+ return { profile: 'standard' };
9
7
  if (v === 'certified' || v === 'cert')
10
- return 'certified';
11
- throw new Error(`Invalid --profile: ${raw}. Expected one of: core|default|strict|certified`);
8
+ return { profile: 'certified' };
9
+ if (v === 'strict')
10
+ return { profile: 'standard', legacyPreset: 'strict' }; // deprecated alias
11
+ throw new Error(`Invalid --profile: ${raw}. Expected one of: core|standard|certified`);
12
12
  }
13
13
  function normalizeValidate(raw) {
14
14
  const v = (raw ?? '').trim().toLowerCase();
@@ -20,24 +20,42 @@ function normalizeValidate(raw) {
20
20
  return 'off';
21
21
  throw new Error(`Invalid --validate: ${raw}. Expected one of: auto|on|off`);
22
22
  }
23
+ function normalizeStructured(raw) {
24
+ const v = (raw ?? '').trim().toLowerCase();
25
+ if (v === '' || v === 'auto')
26
+ return 'auto';
27
+ if (v === 'off' || v === 'none' || v === 'false' || v === '0')
28
+ return 'off';
29
+ if (v === 'prompt')
30
+ return 'prompt';
31
+ if (v === 'native')
32
+ return 'native';
33
+ throw new Error(`Invalid --structured: ${raw}. Expected one of: auto|off|prompt|native`);
34
+ }
23
35
  export function resolveExecutionPolicy(input) {
24
- const profile = normalizeProfile(input.profile);
36
+ const { profile, legacyPreset } = parseProfile(input.profile);
25
37
  // Base defaults per profile.
26
- let validate = profile === 'core' ? 'off' : 'on';
38
+ // standard: auto validation chooses based on module tier/strictness in the runner
39
+ let validate = profile === 'core' ? 'off' : 'auto';
27
40
  let audit = false;
28
41
  let enableRepair = true;
29
42
  let requireV22 = false;
30
- if (profile === 'strict') {
31
- validate = 'on';
32
- audit = false;
33
- enableRepair = true;
34
- requireV22 = false;
35
- }
43
+ let structured = 'auto';
36
44
  if (profile === 'certified') {
37
45
  validate = 'on';
38
46
  audit = true;
39
47
  enableRepair = false; // certification prefers fail-fast over runtime repair
40
48
  requireV22 = true;
49
+ structured = 'auto';
50
+ }
51
+ // Legacy preset: strict = validate on, no audit, keep repair on, do not require v2.2.
52
+ // This keeps backward compatibility while presenting only core/standard/certified externally.
53
+ if (legacyPreset === 'strict') {
54
+ validate = 'on';
55
+ audit = false;
56
+ enableRepair = true;
57
+ requireV22 = false;
58
+ structured = 'auto';
41
59
  }
42
60
  // CLI overrides.
43
61
  const validateExplicit = input.validate != null || Boolean(input.noValidate);
@@ -50,10 +68,13 @@ export function resolveExecutionPolicy(input) {
50
68
  if (typeof input.audit === 'boolean') {
51
69
  audit = input.audit;
52
70
  }
71
+ if (input.structured != null) {
72
+ structured = normalizeStructured(input.structured);
73
+ }
53
74
  // Trigger rule: if audit is enabled and validate wasn't explicitly turned off,
54
75
  // force validation on (auditing without validation is usually not meaningful).
55
76
  if (audit && !(validateExplicit && validate === 'off')) {
56
77
  validate = 'on';
57
78
  }
58
- return { profile, validate, audit, enableRepair, requireV22 };
79
+ return { profile, validate, audit, enableRepair, structured, requireV22 };
59
80
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Base Provider - Abstract class for all LLM providers
3
3
  */
4
- import type { Provider, InvokeParams, InvokeResult } from '../types.js';
4
+ import type { Provider, InvokeParams, InvokeResult, ProviderCapabilities } from '../types.js';
5
5
  export declare abstract class BaseProvider implements Provider {
6
6
  abstract name: string;
7
7
  abstract invoke(params: InvokeParams): Promise<InvokeResult>;
8
8
  abstract isConfigured(): boolean;
9
+ getCapabilities(): ProviderCapabilities;
9
10
  /**
10
11
  * Check if this provider supports streaming.
11
12
  * Override in subclasses that implement streaming.
@@ -2,6 +2,12 @@
2
2
  * Base Provider - Abstract class for all LLM providers
3
3
  */
4
4
  export class BaseProvider {
5
+ getCapabilities() {
6
+ return {
7
+ structuredOutput: 'prompt',
8
+ streaming: this.supportsStreaming(),
9
+ };
10
+ }
5
11
  /**
6
12
  * Check if this provider supports streaming.
7
13
  * Override in subclasses that implement streaming.
@@ -16,6 +16,11 @@ export declare class GeminiProvider extends BaseProvider {
16
16
  * Gemini supports streaming.
17
17
  */
18
18
  supportsStreaming(): boolean;
19
+ getCapabilities(): {
20
+ structuredOutput: "native";
21
+ streaming: boolean;
22
+ nativeSchemaDialect: "gemini-responseSchema";
23
+ };
19
24
  /**
20
25
  * Clean JSON Schema for Gemini API compatibility
21
26
  * Removes unsupported fields like additionalProperties
@@ -23,6 +23,16 @@ export class GeminiProvider extends BaseProvider {
23
23
  supportsStreaming() {
24
24
  return true;
25
25
  }
26
+ getCapabilities() {
27
+ // Gemini has a native response schema surface (generationConfig.responseSchema),
28
+ // but it is NOT JSON Schema. The runner will treat this as "native but non-JSON-schema"
29
+ // and will safely downgrade to prompt-guided JSON unless/ until a real dialect mapping exists.
30
+ return {
31
+ structuredOutput: 'native',
32
+ streaming: true,
33
+ nativeSchemaDialect: 'gemini-responseSchema',
34
+ };
35
+ }
26
36
  /**
27
37
  * Clean JSON Schema for Gemini API compatibility
28
38
  * Removes unsupported fields like additionalProperties
@@ -36,10 +46,36 @@ export class GeminiProvider extends BaseProvider {
36
46
  if (obj && typeof obj === 'object') {
37
47
  const result = {};
38
48
  for (const [key, value] of Object.entries(obj)) {
39
- // Gemini's responseSchema does not accept JSON-Schema `const`.
40
- // Convert `{ const: X }` into `{ enum: [X] }` for compatibility.
49
+ // Gemini's responseSchema has a restricted schema subset.
50
+ // In particular:
51
+ // - `const` is not supported
52
+ // - `enum` values appear to be string-only in the API surface
41
53
  if (key === 'const') {
42
- result.enum = [clean(value)];
54
+ // Preserve type info as best-effort but drop the exact-value constraint.
55
+ // (Core runtime will still validate/repair the envelope.)
56
+ if (value === null) {
57
+ // Leave unconstrained; null typing is not consistently supported.
58
+ }
59
+ else if (typeof value === 'boolean') {
60
+ result.type = 'boolean';
61
+ }
62
+ else if (typeof value === 'number') {
63
+ result.type = 'number';
64
+ }
65
+ else if (typeof value === 'string') {
66
+ result.type = 'string';
67
+ // String enums are supported, so we can keep a single-value constraint.
68
+ result.enum = [value];
69
+ }
70
+ continue;
71
+ }
72
+ if (key === 'enum') {
73
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
74
+ result.enum = value.map((v) => v);
75
+ }
76
+ else {
77
+ // Drop non-string enums for Gemini compatibility.
78
+ }
43
79
  continue;
44
80
  }
45
81
  if (!unsupportedFields.includes(key)) {
@@ -56,15 +92,29 @@ export class GeminiProvider extends BaseProvider {
56
92
  * Build request body for Gemini API
57
93
  */
58
94
  buildRequestBody(params) {
95
+ // If the caller wants schema guidance but not native enforcement, inject the schema into the prompt.
96
+ // (Gemini's native responseSchema supports only a restricted schema subset and can reject valid JSON Schema.)
97
+ const mode = params.jsonSchemaMode ?? (params.jsonSchema ? 'prompt' : undefined);
98
+ let messages = params.messages;
99
+ if (params.jsonSchema && mode === 'prompt') {
100
+ const lastUserIdx = messages.findLastIndex(m => m.role === 'user');
101
+ if (lastUserIdx >= 0) {
102
+ messages = [...messages];
103
+ messages[lastUserIdx] = {
104
+ ...messages[lastUserIdx],
105
+ content: messages[lastUserIdx].content + this.buildJsonPrompt(params.jsonSchema),
106
+ };
107
+ }
108
+ }
59
109
  // Convert messages to Gemini format
60
- const contents = params.messages
110
+ const contents = messages
61
111
  .filter(m => m.role !== 'system')
62
112
  .map(m => ({
63
113
  role: m.role === 'assistant' ? 'model' : 'user',
64
114
  parts: [{ text: m.content }]
65
115
  }));
66
116
  // Add system instruction if present
67
- const systemMessage = params.messages.find(m => m.role === 'system');
117
+ const systemMessage = messages.find(m => m.role === 'system');
68
118
  const body = {
69
119
  contents,
70
120
  generationConfig: {
@@ -75,8 +125,8 @@ export class GeminiProvider extends BaseProvider {
75
125
  if (systemMessage) {
76
126
  body.systemInstruction = { parts: [{ text: systemMessage.content }] };
77
127
  }
78
- // Add JSON schema constraint if provided
79
- if (params.jsonSchema) {
128
+ // Add JSON schema constraint if provided and caller requested native mode.
129
+ if (params.jsonSchema && mode === 'native') {
80
130
  const cleanedSchema = this.cleanSchemaForGemini(params.jsonSchema);
81
131
  body.generationConfig = {
82
132
  ...body.generationConfig,
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Provider Registry
3
+ *
4
+ * Publish-grade note:
5
+ * Providers expose a small capability surface (structured output, streaming).
6
+ * The runner uses this to decide whether to pass native schemas or prompt-only guidance.
3
7
  */
4
- import type { Provider } from '../types.js';
8
+ import type { Provider, StructuredOutputMode } from '../types.js';
5
9
  export { BaseProvider } from './base.js';
6
10
  export { GeminiProvider } from './gemini.js';
7
11
  export { OpenAIProvider } from './openai.js';
@@ -16,4 +20,9 @@ export declare function listProviders(): Array<{
16
20
  name: string;
17
21
  configured: boolean;
18
22
  model: string;
23
+ structuredOutput: StructuredOutputMode;
24
+ structuredJsonSchema: Exclude<StructuredOutputMode, 'native'> | 'native';
25
+ nativeSchemaDialect?: string;
26
+ maxNativeSchemaBytes?: number;
27
+ streaming: boolean;
19
28
  }>;
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * Provider Registry
3
+ *
4
+ * Publish-grade note:
5
+ * Providers expose a small capability surface (structured output, streaming).
6
+ * The runner uses this to decide whether to pass native schemas or prompt-only guidance.
3
7
  */
4
8
  import { GeminiProvider } from './gemini.js';
5
9
  import { OpenAIProvider } from './openai.js';
@@ -60,15 +64,37 @@ export function getProvider(name, model) {
60
64
  }
61
65
  return factory(modelOverride);
62
66
  }
67
+ function safeCapabilities(p) {
68
+ const caps = p.getCapabilities?.();
69
+ if (caps)
70
+ return caps;
71
+ return { structuredOutput: 'prompt', streaming: p.supportsStreaming?.() ?? false };
72
+ }
73
+ function providerRow(name, configured, model, make) {
74
+ const caps = safeCapabilities(make());
75
+ const structuredJsonSchema = caps.structuredOutput === 'native' && (caps.nativeSchemaDialect ?? 'json-schema') !== 'json-schema'
76
+ ? 'prompt'
77
+ : caps.structuredOutput;
78
+ return {
79
+ name,
80
+ configured,
81
+ model,
82
+ structuredOutput: caps.structuredOutput,
83
+ structuredJsonSchema,
84
+ nativeSchemaDialect: caps.nativeSchemaDialect,
85
+ maxNativeSchemaBytes: caps.maxNativeSchemaBytes,
86
+ streaming: caps.streaming,
87
+ };
88
+ }
63
89
  export function listProviders() {
64
90
  return [
65
- { name: 'gemini', configured: !!process.env.GEMINI_API_KEY, model: 'gemini-3-flash' },
66
- { name: 'openai', configured: !!process.env.OPENAI_API_KEY, model: 'gpt-5.2' },
67
- { name: 'anthropic', configured: !!process.env.ANTHROPIC_API_KEY, model: 'claude-sonnet-4.5' },
68
- { name: 'deepseek', configured: !!process.env.DEEPSEEK_API_KEY, model: 'deepseek-v3.2' },
69
- { name: 'minimax', configured: !!process.env.MINIMAX_API_KEY, model: 'MiniMax-M2.1' },
70
- { name: 'moonshot', configured: !!process.env.MOONSHOT_API_KEY, model: 'kimi-k2.5' },
71
- { name: 'qwen', configured: !!(process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY), model: 'qwen3-max' },
72
- { name: 'ollama', configured: true, model: 'llama4 (local)' },
91
+ providerRow('gemini', !!process.env.GEMINI_API_KEY, 'gemini-3-flash', () => new GeminiProvider('')),
92
+ providerRow('openai', !!process.env.OPENAI_API_KEY, 'gpt-5.2', () => new OpenAIProvider('')),
93
+ providerRow('anthropic', !!process.env.ANTHROPIC_API_KEY, 'claude-sonnet-4.5', () => new AnthropicProvider('')),
94
+ providerRow('deepseek', !!process.env.DEEPSEEK_API_KEY, 'deepseek-v3.2', () => new DeepSeekProvider('')),
95
+ providerRow('minimax', !!process.env.MINIMAX_API_KEY, 'MiniMax-M2.1', () => new MiniMaxProvider('')),
96
+ providerRow('moonshot', !!process.env.MOONSHOT_API_KEY, 'kimi-k2.5', () => new MoonshotProvider('')),
97
+ providerRow('qwen', !!(process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY), 'qwen3-max', () => new QwenProvider('')),
98
+ providerRow('ollama', true, 'llama4 (local)', () => new OllamaProvider()),
73
99
  ];
74
100
  }
@@ -10,5 +10,8 @@ export declare class MoonshotProvider extends BaseProvider {
10
10
  private baseUrl;
11
11
  constructor(apiKey?: string, model?: string);
12
12
  isConfigured(): boolean;
13
+ private buildRequestBody;
14
+ private parseRequiredTemperature;
15
+ private fetchOnce;
13
16
  invoke(params: InvokeParams): Promise<InvokeResult>;
14
17
  }
@@ -15,31 +15,56 @@ export class MoonshotProvider extends BaseProvider {
15
15
  isConfigured() {
16
16
  return !!this.apiKey;
17
17
  }
18
- async invoke(params) {
19
- if (!this.isConfigured()) {
20
- throw new Error('Moonshot API key not configured. Set MOONSHOT_API_KEY environment variable.');
21
- }
22
- const url = `${this.baseUrl}/chat/completions`;
18
+ buildRequestBody(params, overrides) {
19
+ // Moonshot (Kimi) model-specific constraints:
20
+ // - Some Kimi models reject arbitrary temperatures (e.g. `kimi-k2.5` only allows 1).
21
+ const model = this.model;
22
+ let temperature = overrides?.temperature ?? params.temperature ?? 0.7;
23
+ if (model === 'kimi-k2.5')
24
+ temperature = 1;
23
25
  const body = {
24
- model: this.model,
25
- messages: params.messages.map(m => ({ role: m.role, content: m.content })),
26
- temperature: params.temperature ?? 0.7,
26
+ model,
27
+ messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
28
+ temperature,
27
29
  max_tokens: params.maxTokens ?? 4096,
28
30
  };
29
31
  // Add JSON mode if schema provided
30
32
  if (params.jsonSchema) {
31
33
  body.response_format = { type: 'json_object' };
32
- const lastUserIdx = params.messages.findLastIndex(m => m.role === 'user');
34
+ const lastUserIdx = params.messages.findLastIndex((m) => m.role === 'user');
33
35
  if (lastUserIdx >= 0) {
34
36
  const messages = [...params.messages];
35
37
  messages[lastUserIdx] = {
36
38
  ...messages[lastUserIdx],
37
39
  content: messages[lastUserIdx].content + this.buildJsonPrompt(params.jsonSchema),
38
40
  };
39
- body.messages = messages.map(m => ({ role: m.role, content: m.content }));
41
+ body.messages = messages.map((m) => ({ role: m.role, content: m.content }));
40
42
  }
41
43
  }
42
- const response = await fetch(url, {
44
+ return body;
45
+ }
46
+ parseRequiredTemperature(errorText) {
47
+ const s = String(errorText ?? '');
48
+ // Example: {"error":{"message":"invalid temperature: only 1 is allowed for this model","type":"invalid_request_error"}}
49
+ // Also tolerate plain text.
50
+ const m = s.match(/invalid temperature[^0-9]*only\s+([0-9]+(?:\.[0-9]+)?)\s+is allowed/i);
51
+ if (m?.[1]) {
52
+ const n = Number(m[1]);
53
+ if (Number.isFinite(n))
54
+ return n;
55
+ }
56
+ // Some variants omit "only" but still contain a single allowed value.
57
+ const m2 = s.match(/invalid temperature[^0-9]*([0-9]+(?:\.[0-9]+)?)\s+is allowed/i);
58
+ if (m2?.[1]) {
59
+ const n = Number(m2[1]);
60
+ if (Number.isFinite(n))
61
+ return n;
62
+ }
63
+ return null;
64
+ }
65
+ async fetchOnce(body) {
66
+ const url = `${this.baseUrl}/chat/completions`;
67
+ return await fetch(url, {
43
68
  method: 'POST',
44
69
  headers: {
45
70
  'Content-Type': 'application/json',
@@ -47,9 +72,29 @@ export class MoonshotProvider extends BaseProvider {
47
72
  },
48
73
  body: JSON.stringify(body),
49
74
  });
75
+ }
76
+ async invoke(params) {
77
+ if (!this.isConfigured()) {
78
+ throw new Error('Moonshot API key not configured. Set MOONSHOT_API_KEY environment variable.');
79
+ }
80
+ // Moonshot is strict about some generation params for certain models.
81
+ // Keep UX stable: if we hit a known parameter-compat error, retry once with a safer value.
82
+ const initialBody = this.buildRequestBody(params);
83
+ let response = await this.fetchOnce(initialBody);
50
84
  if (!response.ok) {
51
- const error = await response.text();
52
- throw new Error(`Moonshot API error: ${response.status} - ${error}`);
85
+ const errorText = await response.text();
86
+ const requiredTemp = this.parseRequiredTemperature(errorText);
87
+ if (response.status === 400 && requiredTemp !== null) {
88
+ const retryBody = this.buildRequestBody(params, { temperature: requiredTemp });
89
+ response = await this.fetchOnce(retryBody);
90
+ if (!response.ok) {
91
+ const retryError = await response.text();
92
+ throw new Error(`Moonshot API error: ${response.status} - ${retryError}`);
93
+ }
94
+ }
95
+ else {
96
+ throw new Error(`Moonshot API error: ${response.status} - ${errorText}`);
97
+ }
53
98
  }
54
99
  const data = await response.json();
55
100
  const content = data.choices?.[0]?.message?.content || '';
@@ -39,9 +39,17 @@ export interface RegistryVerifyResult {
39
39
  checked: number;
40
40
  passed: number;
41
41
  failed: number;
42
+ /**
43
+ * Failure diagnostics (best-effort).
44
+ *
45
+ * This structure is intentionally extensible. Consumers should treat unknown keys as optional.
46
+ */
42
47
  failures: Array<{
43
48
  module: string;
44
49
  reason: string;
50
+ phase?: 'entry' | 'download' | 'size' | 'checksum' | 'extract' | 'files' | 'identity';
51
+ tarball_ref?: string;
52
+ tarball_resolved?: string;
45
53
  }>;
46
54
  }
47
55
  export declare function buildRegistryAssets(opts: BuildRegistryOptions): Promise<RegistryBuildResult>;
@@ -24,6 +24,21 @@ function tarballFileName(tarballRef) {
24
24
  }
25
25
  return path.basename(tarballRef);
26
26
  }
27
+ function resolveRemoteUrl(indexUrl, ref) {
28
+ if (isHttpUrl(ref))
29
+ return ref;
30
+ // Allow relative tarball refs in remote indexes for portability.
31
+ // Example:
32
+ // - index: https://host/registry.json
33
+ // - tarball: demo-1.0.0.tar.gz
34
+ // resolves to https://host/demo-1.0.0.tar.gz
35
+ try {
36
+ return new URL(ref, indexUrl).toString();
37
+ }
38
+ catch {
39
+ return ref;
40
+ }
41
+ }
27
42
  async function fetchTextWithLimit(url, maxBytes, timeoutMs) {
28
43
  const controller = new AbortController();
29
44
  const t = setTimeout(() => controller.abort(), timeoutMs);
@@ -578,8 +593,9 @@ export async function verifyRegistryAssets(opts) {
578
593
  const concurrency = Math.max(1, Math.min(8, Math.floor(desiredConcurrency)));
579
594
  const localTarPath = async (moduleName, tarballRef) => {
580
595
  const fileName = tarballFileName(tarballRef);
581
- // Avoid collisions when we download into a temp dir (remote verify).
582
- if (wantRemote && tmpAssetsRoot && !opts.assetsDir) {
596
+ // Avoid collisions on remote verify (query strings can produce identical basenames).
597
+ // Even when the caller provides --assets-dir, we isolate per module to keep verification correct.
598
+ if (wantRemote) {
583
599
  const p = path.join(assetsDir, moduleName, fileName);
584
600
  await fs.mkdir(path.dirname(p), { recursive: true });
585
601
  return p;
@@ -589,24 +605,30 @@ export async function verifyRegistryAssets(opts) {
589
605
  const verifyOne = async (moduleName, entry) => {
590
606
  checked += 1;
591
607
  let tarPathForCleanup = null;
608
+ let phase = 'entry';
592
609
  try {
593
610
  const dist = entry.distribution ?? {};
594
- const tarballUrl = String(dist.tarball ?? '');
611
+ const tarballRef = String(dist.tarball ?? '');
595
612
  const checksum = String(dist.checksum ?? '');
596
- const sizeBytes = Number(dist.size_bytes ?? NaN);
613
+ const sizeBytesRaw = dist.size_bytes;
614
+ const expectedSizeBytes = Number.isFinite(Number(sizeBytesRaw)) ? Number(sizeBytesRaw) : null;
597
615
  const expectedFiles = Array.isArray(dist.files) ? dist.files.map(String) : [];
598
- if (!tarballUrl)
616
+ if (!tarballRef)
599
617
  throw new Error('Missing distribution.tarball');
618
+ const tarballUrl = wantRemote ? resolveRemoteUrl(opts.registryIndexPath, tarballRef) : tarballRef;
600
619
  const tarPath = await localTarPath(moduleName, tarballUrl);
601
620
  tarPathForCleanup = tarPath;
602
621
  if (wantRemote) {
603
622
  if (!isHttpUrl(tarballUrl)) {
604
- throw new Error(`Remote verify requires http(s) tarball URL, got: ${tarballUrl}`);
623
+ throw new Error(`Remote verify requires http(s) tarball URL (or a relative URL), got: ${tarballRef}`);
605
624
  }
625
+ phase = 'download';
606
626
  const downloaded = await downloadToFileWithSha256(tarballUrl, tarPath, maxTarballBytes, fetchTimeoutMs);
607
- if (Number.isFinite(sizeBytes) && downloaded.sizeBytes !== sizeBytes) {
608
- throw new Error(`Size mismatch: expected ${sizeBytes}, got ${downloaded.sizeBytes}`);
627
+ phase = 'size';
628
+ if (expectedSizeBytes !== null && downloaded.sizeBytes !== expectedSizeBytes) {
629
+ throw new Error(`Size mismatch: expected ${expectedSizeBytes}, got ${downloaded.sizeBytes}`);
609
630
  }
631
+ phase = 'checksum';
610
632
  const m = checksum.match(/^sha256:([a-f0-9]{64})$/);
611
633
  if (!m)
612
634
  throw new Error(`Unsupported checksum format: ${checksum}`);
@@ -615,11 +637,12 @@ export async function verifyRegistryAssets(opts) {
615
637
  throw new Error(`Checksum mismatch: expected ${expectedSha}, got ${downloaded.sha256}`);
616
638
  }
617
639
  }
640
+ phase = 'size';
618
641
  const st = await fs.stat(tarPath);
619
- if (!Number.isFinite(sizeBytes))
620
- throw new Error('Missing distribution.size_bytes');
621
- if (st.size !== sizeBytes)
622
- throw new Error(`Size mismatch: expected ${sizeBytes}, got ${st.size}`);
642
+ if (expectedSizeBytes !== null && st.size !== expectedSizeBytes) {
643
+ throw new Error(`Size mismatch: expected ${expectedSizeBytes}, got ${st.size}`);
644
+ }
645
+ phase = 'checksum';
623
646
  const m = checksum.match(/^sha256:([a-f0-9]{64})$/);
624
647
  if (!m)
625
648
  throw new Error(`Unsupported checksum format: ${checksum}`);
@@ -630,6 +653,7 @@ export async function verifyRegistryAssets(opts) {
630
653
  // Extract and validate contents (layout + file list).
631
654
  const tmp = await fs.mkdtemp(path.join(tmpdir(), 'cog-reg-verify-'));
632
655
  try {
656
+ phase = 'extract';
633
657
  const extractedRoot = path.join(tmp, 'pkg');
634
658
  await fs.mkdir(extractedRoot, { recursive: true });
635
659
  await extractTarGzFile(tarPath, extractedRoot, {
@@ -651,6 +675,7 @@ export async function verifyRegistryAssets(opts) {
651
675
  throw new Error('Root directory is not a valid module');
652
676
  }
653
677
  if (expectedFiles.length > 0) {
678
+ phase = 'files';
654
679
  const actualFiles = await listFiles(moduleDir);
655
680
  const exp = expectedFiles.slice().sort();
656
681
  const act = actualFiles.slice().sort();
@@ -664,6 +689,7 @@ export async function verifyRegistryAssets(opts) {
664
689
  }
665
690
  // Basic identity check: module.yaml version matches registry identity.version
666
691
  try {
692
+ phase = 'identity';
667
693
  const y = await readModuleMeta(path.join(moduleDir, 'module.yaml'));
668
694
  const identityVersion = String(entry.identity?.version ?? '').trim();
669
695
  if (identityVersion && y.version !== identityVersion) {
@@ -684,7 +710,16 @@ export async function verifyRegistryAssets(opts) {
684
710
  passed += 1;
685
711
  }
686
712
  catch (e) {
687
- failures.push({ module: moduleName, reason: e instanceof Error ? e.message : String(e) });
713
+ const dist = entry?.distribution ?? {};
714
+ const tarball_ref = typeof dist.tarball === 'string' ? dist.tarball : undefined;
715
+ const tarball_resolved = tarball_ref && wantRemote ? resolveRemoteUrl(opts.registryIndexPath, tarball_ref) : tarball_ref;
716
+ failures.push({
717
+ module: moduleName,
718
+ reason: e instanceof Error ? e.message : String(e),
719
+ phase: (typeof phase === 'string' ? phase : undefined),
720
+ tarball_ref,
721
+ tarball_resolved,
722
+ });
688
723
  }
689
724
  finally {
690
725
  // If we downloaded tarballs into a temp dir, keep disk usage bounded.