diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4546741..6531bfb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Check dependency baseline + run: bun run deps:check + - name: Type check run: bunx tsc --noEmit diff --git a/deps-baseline.json b/deps-baseline.json new file mode 100644 index 0000000..8a91a32 --- /dev/null +++ b/deps-baseline.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^3.24.0" + } +} diff --git a/package.json b/package.json index 86e122f..97edf0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aelfscan/agent-skills", - "version": "0.2.0", + "version": "0.2.1", "description": "AelfScan explorer skill toolkit for AI agents: MCP, CLI, and SDK interfaces.", "type": "module", "main": "index.ts", @@ -38,7 +38,8 @@ "test:integration": "bun test tests/integration/", "test:e2e": "bun test tests/e2e/", "coverage:gate": "bun run scripts/coverage-gate.ts", - "test:coverage:ci": "COVERAGE_MIN_LINES=85 COVERAGE_MIN_FUNCS=80 bun run test:unit:coverage && bun run coverage:gate" + "test:coverage:ci": "COVERAGE_MIN_LINES=85 COVERAGE_MIN_FUNCS=80 bun run test:unit:coverage && bun run coverage:gate", + "deps:check": "bun run scripts/check-deps-baseline.ts" }, "keywords": [ "aelfscan", diff --git a/scripts/check-deps-baseline.ts b/scripts/check-deps-baseline.ts new file mode 100644 index 0000000..812c4e9 --- /dev/null +++ b/scripts/check-deps-baseline.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env bun +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +type Baseline = { + dependencies: Record; +}; + +function readJson(filePath: string): T { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; +} + +function main() { + const cwd = process.cwd(); + const baselinePath = resolve(cwd, 'deps-baseline.json'); + const packagePath = resolve(cwd, 'package.json'); + + if (!existsSync(baselinePath)) { + console.error(`[deps:check] missing deps-baseline.json at ${baselinePath}`); + process.exit(1); + } + + if (!existsSync(packagePath)) { + console.error(`[deps:check] missing package.json at ${packagePath}`); + process.exit(1); + } + + const baseline = readJson(baselinePath); + const pkg = readJson(packagePath); + const declaredDeps = { + ...(pkg.dependencies || {}), + ...(pkg.devDependencies || {}), + } as Record; + + const failures: string[] = []; + for (const [name, expected] of Object.entries(baseline.dependencies || {})) { + const actual = declaredDeps[name]; + if (!actual) { + failures.push(`${name}: missing (expected ${expected})`); + continue; + } + if (actual !== expected) { + failures.push(`${name}: expected ${expected}, got ${actual}`); + } + } + + if (failures.length > 0) { + console.error('[deps:check] dependency baseline mismatch:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log('[deps:check] passed'); +} + +main(); diff --git a/src/mcp/output.ts b/src/mcp/output.ts index 55d1772..72f3d9b 100644 --- a/src/mcp/output.ts +++ b/src/mcp/output.ts @@ -1,128 +1,9 @@ import { getConfig } from '../../lib/config.js'; import type { ToolOutputPolicy } from '../tooling/tool-descriptors.js'; - -interface TruncationMeta { - truncated: boolean; - maxItems: number; - maxChars: number; - originalSizeEstimate: number; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function summarizeValue(value: unknown): Record { - if (Array.isArray(value)) { - return { - type: 'array', - length: value.length, - preview: value.slice(0, 3), - }; - } - - if (isRecord(value)) { - const keys = Object.keys(value); - return { - type: 'object', - keys, - keyCount: keys.length, - }; - } - - return { - type: typeof value, - value, - }; -} - -function truncateArrays(value: unknown, maxItems: number): { value: unknown; truncated: boolean } { - if (Array.isArray(value)) { - const truncatedItems = value.slice(0, maxItems).map(item => truncateArrays(item, maxItems)); - const wasTruncated = value.length > maxItems || truncatedItems.some(item => item.truncated); - - return { - value: truncatedItems.map(item => item.value), - truncated: wasTruncated, - }; - } - - if (isRecord(value)) { - let truncated = false; - const next: Record = {}; - - for (const [key, itemValue] of Object.entries(value)) { - const child = truncateArrays(itemValue, maxItems); - if (child.truncated) { - truncated = true; - } - next[key] = child.value; - } - - return { - value: next, - truncated, - }; - } - - return { - value, - truncated: false, - }; -} - -function stripRawByConfig(value: unknown, includeRaw: boolean): unknown { - if (includeRaw) { - return value; - } - - if (!isRecord(value)) { - return value; - } - - const next = { ...value }; - delete next.raw; - return next; -} - -function attachMeta(value: unknown, meta: TruncationMeta): unknown { - if (isRecord(value)) { - return { - ...value, - meta, - }; - } - - return { - data: value, - meta, - }; -} - -function safeSerialize(value: unknown): string { - try { - return JSON.stringify(value, null, 2); - } catch { - return JSON.stringify({ data: summarizeValue(value) }, null, 2); - } -} - -function shrinkForMaxChars(value: unknown, meta: TruncationMeta): unknown { - if (isRecord(value)) { - return { - success: value.success, - traceId: value.traceId, - dataSummary: summarizeValue(value.data), - error: value.error, - meta, - }; - } - - return { - dataSummary: summarizeValue(value), - meta, - }; -} +import { + applyOutputGovernance, + applySummaryPolicy as applySummaryPolicyFields, +} from '../tooling/mcp-output-governance.js'; function applySummaryPolicy( value: unknown, @@ -133,29 +14,7 @@ function applySummaryPolicy( return { value, truncated: false }; } - if (!isRecord(value) || !isRecord(value.data)) { - return { value, truncated: false }; - } - - const data = value.data as Record; - const nextData = { ...data }; - let truncated = false; - - for (const key of ['list', 'items', 'blocks', 'transactions', 'logEvents']) { - const item = nextData[key]; - if (Array.isArray(item) && item.length > maxItems) { - nextData[key] = item.slice(0, maxItems); - truncated = true; - } - } - - return { - value: { - ...value, - data: nextData, - }, - truncated, - }; + return applySummaryPolicyFields(value, maxItems); } export function asMcpResult(data: unknown, outputPolicy: ToolOutputPolicy = 'normal') { @@ -163,30 +22,23 @@ export function asMcpResult(data: unknown, outputPolicy: ToolOutputPolicy = 'nor const maxItems = Math.max(1, config.mcpMaxItems); const maxChars = Math.max(1, config.mcpMaxChars); - const stripped = stripRawByConfig(data, config.mcpIncludeRaw); - const summaryApplied = applySummaryPolicy(stripped, outputPolicy, maxItems); - const truncatedArrays = truncateArrays(summaryApplied.value, maxItems); - - const initialMeta: TruncationMeta = { - truncated: summaryApplied.truncated || truncatedArrays.truncated, + const summaryApplied = applySummaryPolicy(data, outputPolicy, maxItems); + const governed = applyOutputGovernance(summaryApplied.value, { maxItems, maxChars, - originalSizeEstimate: safeSerialize(truncatedArrays.value).length, - }; - - const meta: TruncationMeta = initialMeta; - let payload = attachMeta(truncatedArrays.value, meta); - let serialized = safeSerialize(payload); - - if (serialized.length > maxChars) { - const reducedMeta: TruncationMeta = { - ...meta, - truncated: true, + includeRaw: config.mcpIncludeRaw, + }); + let payload = governed.payload as Record; + if (summaryApplied.truncated && payload?.meta && typeof payload.meta === 'object') { + payload = { + ...payload, + meta: { + ...(payload.meta as Record), + truncated: true, + }, }; - - payload = shrinkForMaxChars(truncatedArrays.value, reducedMeta); - serialized = safeSerialize(payload); } + const serialized = JSON.stringify(payload, null, 2); return { content: [ diff --git a/src/tooling/mcp-output-governance.ts b/src/tooling/mcp-output-governance.ts new file mode 100644 index 0000000..13385ba --- /dev/null +++ b/src/tooling/mcp-output-governance.ts @@ -0,0 +1,190 @@ +export interface TruncationMeta { + truncated: boolean; + maxItems: number; + maxChars: number; + originalSizeEstimate: number; +} + +export interface OutputGovernanceOptions { + maxItems: number; + maxChars: number; + includeRaw: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function summarizeValue(value: unknown): Record { + if (Array.isArray(value)) { + return { + type: 'array', + length: value.length, + preview: value.slice(0, 3), + }; + } + + if (isRecord(value)) { + const keys = Object.keys(value); + return { + type: 'object', + keys, + keyCount: keys.length, + }; + } + + return { + type: typeof value, + value, + }; +} + +function truncateArrays(value: unknown, maxItems: number): { value: unknown; truncated: boolean } { + if (Array.isArray(value)) { + const truncatedItems = value.slice(0, maxItems).map(item => truncateArrays(item, maxItems)); + const wasTruncated = value.length > maxItems || truncatedItems.some(item => item.truncated); + + return { + value: truncatedItems.map(item => item.value), + truncated: wasTruncated, + }; + } + + if (isRecord(value)) { + let truncated = false; + const next: Record = {}; + + for (const [key, itemValue] of Object.entries(value)) { + const child = truncateArrays(itemValue, maxItems); + if (child.truncated) { + truncated = true; + } + next[key] = child.value; + } + + return { + value: next, + truncated, + }; + } + + return { + value, + truncated: false, + }; +} + +function stripRawByConfig(value: unknown, includeRaw: boolean): unknown { + if (includeRaw) { + return value; + } + + if (!isRecord(value)) { + return value; + } + + const next = { ...value }; + delete next.raw; + return next; +} + +function attachMeta(value: unknown, meta: TruncationMeta): unknown { + if (isRecord(value)) { + return { + ...value, + meta, + }; + } + + return { + data: value, + meta, + }; +} + +function safeSerialize(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return JSON.stringify({ data: summarizeValue(value) }, null, 2); + } +} + +function shrinkForMaxChars(value: unknown, meta: TruncationMeta): unknown { + if (isRecord(value)) { + return { + success: value.success, + traceId: value.traceId, + dataSummary: summarizeValue(value.data), + error: value.error, + meta, + }; + } + + return { + dataSummary: summarizeValue(value), + meta, + }; +} + +export function applyOutputGovernance( + value: unknown, + options: OutputGovernanceOptions, +): { payload: unknown; serialized: string; meta: TruncationMeta } { + const maxItems = Math.max(1, options.maxItems); + const maxChars = Math.max(1, options.maxChars); + const stripped = stripRawByConfig(value, options.includeRaw); + const truncatedArrays = truncateArrays(stripped, maxItems); + + const initialMeta: TruncationMeta = { + truncated: truncatedArrays.truncated, + maxItems, + maxChars, + originalSizeEstimate: safeSerialize(truncatedArrays.value).length, + }; + + let payload = attachMeta(truncatedArrays.value, initialMeta); + let serialized = safeSerialize(payload); + + if (serialized.length > maxChars) { + const reducedMeta: TruncationMeta = { + ...initialMeta, + truncated: true, + }; + + payload = shrinkForMaxChars(truncatedArrays.value, reducedMeta); + serialized = safeSerialize(payload); + return { payload, serialized, meta: reducedMeta }; + } + + return { payload, serialized, meta: initialMeta }; +} + +export function applySummaryPolicy( + value: unknown, + maxItems: number, +): { value: unknown; truncated: boolean } { + if (!isRecord(value) || !isRecord(value.data)) { + return { value, truncated: false }; + } + + const data = value.data as Record; + const nextData = { ...data }; + let truncated = false; + + for (const key of ['list', 'items', 'blocks', 'transactions', 'logEvents']) { + const item = nextData[key]; + if (Array.isArray(item) && item.length > maxItems) { + nextData[key] = item.slice(0, maxItems); + truncated = true; + } + } + + return { + value: { + ...value, + data: nextData, + }, + truncated, + }; +} diff --git a/tsconfig.json b/tsconfig.json index e692ef7..e5c0303 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,12 @@ "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "resolveJsonModule": true, - "types": ["bun-types"] + "types": [ + "bun-types" + ], + "noEmit": true }, - "include": ["**/*.ts"] + "include": [ + "**/*.ts" + ] }