diff --git a/library/docs/schemas/README.md b/library/docs/schemas/README.md index e72a273..8baf067 100644 --- a/library/docs/schemas/README.md +++ b/library/docs/schemas/README.md @@ -9,6 +9,8 @@ published with a guide, examples, invalid fixtures, and an offline CI check. | `.map/map.config.json` | [`project.schema.json`](../../schemas/project.schema.json) | [Project manifest](project.md) | | Architecture decision record | [`decision.schema.json`](../../schemas/decision.schema.json) | [Decision records](decision.md) | | Pattern and anti-pattern metadata | [`pattern.schema.json`](../../schemas/pattern.schema.json) | [Pattern Schema v1](../../../docs/specifications/PATTERN_SCHEMA.md) | +| Static scan result | [`scan-result.schema.json`](../../schemas/scan-result.schema.json) | [Scan result v1](../../../docs/specifications/SCAN_RESULT.md) | +| Pattern suggestions | [`recommendation-result.schema.json`](../../schemas/recommendation-result.schema.json) | [Recommendation result v1](../../../docs/specifications/RECOMMENDATION_RESULT.md) | Schemas use JSON Schema draft 2020-12. Stable fields are strict; experiments use an `x-` prefix. A schema change that alters accepted meaning requires compatibility and diff --git a/library/schemas/fixtures/recommendation-result/invalid/bad-pattern.json b/library/schemas/fixtures/recommendation-result/invalid/bad-pattern.json new file mode 100644 index 0000000..61d2584 --- /dev/null +++ b/library/schemas/fixtures/recommendation-result/invalid/bad-pattern.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, "kind": "map.recommendation-result", "scan": { "root": "/workspace", "detectedAt": "2026-09-13T00:00:00Z" }, + "recommendations": [{ "pattern": "security.injection", "priority": "high", "rationale": "Review it.", "triggeredBy": ["rag"] }], + "limitations": ["Static signals only."] +} diff --git a/library/schemas/fixtures/recommendation-result/invalid/bad-priority.json b/library/schemas/fixtures/recommendation-result/invalid/bad-priority.json new file mode 100644 index 0000000..eb795a3 --- /dev/null +++ b/library/schemas/fixtures/recommendation-result/invalid/bad-priority.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, "kind": "map.recommendation-result", "scan": { "root": "/workspace", "detectedAt": "2026-09-13T00:00:00Z" }, + "recommendations": [{ "pattern": "security/prompt-injection-defense", "priority": "urgent", "rationale": "Review it.", "triggeredBy": ["rag"] }], + "limitations": ["Static signals only."] +} diff --git a/library/schemas/fixtures/recommendation-result/invalid/no-trigger.json b/library/schemas/fixtures/recommendation-result/invalid/no-trigger.json new file mode 100644 index 0000000..7326931 --- /dev/null +++ b/library/schemas/fixtures/recommendation-result/invalid/no-trigger.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, "kind": "map.recommendation-result", "scan": { "root": "/workspace", "detectedAt": "2026-09-13T00:00:00Z" }, + "recommendations": [{ "pattern": "security/prompt-injection-defense", "priority": "high", "rationale": "Review it.", "triggeredBy": [] }], + "limitations": ["Static signals only."] +} diff --git a/library/schemas/fixtures/recommendation-result/valid/suggestions.json b/library/schemas/fixtures/recommendation-result/valid/suggestions.json new file mode 100644 index 0000000..8309a35 --- /dev/null +++ b/library/schemas/fixtures/recommendation-result/valid/suggestions.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "kind": "map.recommendation-result", + "scan": { "root": "/workspace/example", "detectedAt": "2026-09-13T00:00:00.000Z" }, + "recommendations": [{ "pattern": "security/prompt-injection-defense", "priority": "high", "rationale": "Retrieved text crosses a trust boundary.", "triggeredBy": ["rag"] }], + "limitations": ["Suggestions are review candidates, not proof of absence."] +} diff --git a/library/schemas/fixtures/scan-result/invalid/bad-confidence.json b/library/schemas/fixtures/scan-result/invalid/bad-confidence.json new file mode 100644 index 0000000..be8859c --- /dev/null +++ b/library/schemas/fixtures/scan-result/invalid/bad-confidence.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, "kind": "map.scan-result", "root": "/workspace", "detectedAt": "2026-09-13T00:00:00Z", + "analyzers": [], "inspected": [], "concepts": [{ "concept": "rag", "confidence": 2, "certainty": "detected", "evidence": ["package.json: x"] }], + "limitations": ["Static signals only."] +} diff --git a/library/schemas/fixtures/scan-result/invalid/bad-kind.json b/library/schemas/fixtures/scan-result/invalid/bad-kind.json new file mode 100644 index 0000000..2b073eb --- /dev/null +++ b/library/schemas/fixtures/scan-result/invalid/bad-kind.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, "kind": "map.analysis", "root": "/workspace", "detectedAt": "2026-09-13T00:00:00Z", + "analyzers": [], "inspected": [], "concepts": [], "limitations": ["Static signals only."] +} diff --git a/library/schemas/fixtures/scan-result/invalid/no-limitations.json b/library/schemas/fixtures/scan-result/invalid/no-limitations.json new file mode 100644 index 0000000..fa34ac4 --- /dev/null +++ b/library/schemas/fixtures/scan-result/invalid/no-limitations.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, "kind": "map.scan-result", "root": "/workspace", "detectedAt": "2026-09-13T00:00:00Z", + "analyzers": [], "inspected": [], "concepts": [], "limitations": [] +} diff --git a/library/schemas/fixtures/scan-result/valid/detected.json b/library/schemas/fixtures/scan-result/valid/detected.json new file mode 100644 index 0000000..90c6e02 --- /dev/null +++ b/library/schemas/fixtures/scan-result/valid/detected.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "kind": "map.scan-result", + "root": "/workspace/example", + "detectedAt": "2026-09-13T00:00:00.000Z", + "analyzers": ["dependency-manifest"], + "inspected": ["package.json"], + "concepts": [{ "concept": "tool_calling", "confidence": 0.95, "certainty": "detected", "evidence": ["package.json: ai"] }], + "limitations": ["Dependency declarations do not prove runtime use."] +} diff --git a/library/schemas/recommendation-result.schema.json b/library/schemas/recommendation-result.schema.json new file mode 100644 index 0000000..afca642 --- /dev/null +++ b/library/schemas/recommendation-result.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://map.dev/schemas/recommendation-result.schema.json", + "title": "MAP Recommendation Result v1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "scan", "recommendations", "limitations"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "map.recommendation-result" }, + "scan": { + "type": "object", + "additionalProperties": false, + "required": ["root", "detectedAt"], + "properties": { + "root": { "type": "string", "minLength": 1 }, + "detectedAt": { "type": "string", "format": "date-time" } + } + }, + "recommendations": { "type": "array", "items": { "$ref": "#/$defs/recommendation" } }, + "limitations": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + }, + "$defs": { + "recommendation": { + "type": "object", + "additionalProperties": false, + "required": ["pattern", "priority", "rationale", "triggeredBy"], + "properties": { + "pattern": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*/[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "priority": { "enum": ["high", "medium", "low"] }, + "rationale": { "type": "string", "minLength": 1 }, + "triggeredBy": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, "uniqueItems": true } + } + } + } +} diff --git a/library/schemas/scan-result.schema.json b/library/schemas/scan-result.schema.json new file mode 100644 index 0000000..9d74e47 --- /dev/null +++ b/library/schemas/scan-result.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://map.dev/schemas/scan-result.schema.json", + "title": "MAP Scan Result v1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "root", "detectedAt", "analyzers", "inspected", "concepts", "limitations"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "map.scan-result" }, + "root": { "type": "string", "minLength": 1 }, + "detectedAt": { "type": "string", "format": "date-time" }, + "analyzers": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "inspected": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "concepts": { "type": "array", "items": { "$ref": "#/$defs/concept" } }, + "limitations": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + }, + "$defs": { + "concept": { + "type": "object", + "additionalProperties": false, + "required": ["concept", "confidence", "certainty", "evidence"], + "properties": { + "concept": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "certainty": { "enum": ["detected", "likely", "unknown"] }, + "evidence": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + } + } + } +} diff --git a/library/scripts/validate-schemas.ts b/library/scripts/validate-schemas.ts index cf0a144..1399f87 100644 --- a/library/scripts/validate-schemas.ts +++ b/library/scripts/validate-schemas.ts @@ -23,6 +23,8 @@ const CONTRACTS: readonly Contract[] = [ { name: "project", schema: "project.schema.json", fixtures: "project" }, { name: "decision", schema: "decision.schema.json", fixtures: "decision" }, { name: "pattern", schema: "pattern.schema.json", fixtures: "pattern" }, + { name: "scan-result", schema: "scan-result.schema.json", fixtures: "scan-result" }, + { name: "recommendation-result", schema: "recommendation-result.schema.json", fixtures: "recommendation-result" }, ]; const failures: string[] = []; diff --git a/tooling/packages/cli/src/analyzer/analyzer.ts b/tooling/packages/cli/src/analyzer/analyzer.ts index c6baa57..d0e15d0 100644 --- a/tooling/packages/cli/src/analyzer/analyzer.ts +++ b/tooling/packages/cli/src/analyzer/analyzer.ts @@ -1,13 +1,12 @@ /** - * Module 2 — Analyzer (interfaces only for now). + * Module 2 — Analyzer ports and registry. * * An `Analyzer` inspects a project and reports the AI-architecture concepts it finds. * Analyzers are pluggable and language-scoped (TypeScript, Python, Go, Java, ...). * The registry picks the analyzers that apply to a given project. * - * TODO(module-2): implement a first analyzer (e.g. TypeScript) that detects - * embeddings, vector search, RAG, tool calling, streaming, memory, model routing, - * and prompt guards. Keep detection rules data-driven so new signals are cheap. + * The dependency-manifest implementation is the deterministic baseline. Additional + * source or runtime analyzers implement this port without changing domain results. */ import type { DetectedConcept } from "../domain/index.ts"; diff --git a/tooling/packages/cli/src/analyzer/dependency-manifest.ts b/tooling/packages/cli/src/analyzer/dependency-manifest.ts index ccb3d15..d878155 100644 --- a/tooling/packages/cli/src/analyzer/dependency-manifest.ts +++ b/tooling/packages/cli/src/analyzer/dependency-manifest.ts @@ -28,6 +28,14 @@ interface ManifestSpec { readonly parse: (contents: string) => readonly string[]; } +export const SUPPORTED_MANIFEST_FILES = [ + "package.json", + "requirements.txt", + "pyproject.toml", + "go.mod", + "Cargo.toml", +] as const; + const MANIFESTS: readonly ManifestSpec[] = [ { file: "package.json", ecosystem: "npm", parse: parsePackageJson }, { file: "requirements.txt", ecosystem: "pypi", parse: parseRequirementsTxt }, diff --git a/tooling/packages/cli/src/analyzer/index.ts b/tooling/packages/cli/src/analyzer/index.ts index c924175..0d0e837 100644 --- a/tooling/packages/cli/src/analyzer/index.ts +++ b/tooling/packages/cli/src/analyzer/index.ts @@ -1,6 +1,10 @@ export type { Analyzer, AnalyzerContext } from "./analyzer.ts"; export { AnalyzerRegistry } from "./analyzer.ts"; -export { DependencyManifestAnalyzer, mergeConcepts } from "./dependency-manifest.ts"; +export { + DependencyManifestAnalyzer, + SUPPORTED_MANIFEST_FILES, + mergeConcepts, +} from "./dependency-manifest.ts"; export type { DependencySignal, Ecosystem } from "./signals.ts"; export { DEPENDENCY_SIGNALS } from "./signals.ts"; export { diff --git a/tooling/packages/cli/src/cli/commands/analyze.ts b/tooling/packages/cli/src/cli/commands/analyze.ts index 2d772d3..8cd5007 100644 --- a/tooling/packages/cli/src/cli/commands/analyze.ts +++ b/tooling/packages/cli/src/cli/commands/analyze.ts @@ -11,10 +11,11 @@ import { join, resolve } from "node:path"; import type { Command, CommandContext, CommandResult } from "../command.ts"; import { OK } from "../command.ts"; import type { AnalyzerContext } from "../../analyzer/index.ts"; -import { mergeConcepts } from "../../analyzer/index.ts"; +import { mergeConcepts, SUPPORTED_MANIFEST_FILES } from "../../analyzer/index.ts"; import { MAP_DIR } from "../../config/index.ts"; import { CONCEPTS } from "../../domain/index.ts"; -import type { DetectedArchitecture, DetectedConcept } from "../../domain/index.ts"; +import { certaintyForConfidence } from "../../domain/index.ts"; +import type { DetectedConcept, ScanResult } from "../../domain/index.ts"; import type { Services } from "../../services.ts"; import type { Reporter } from "../../reporting/index.ts"; @@ -23,20 +24,26 @@ export const analyzeCommand: Command = { summary: "Scan the project and detect AI architecture concepts.", usage: "map analyze [path]", args: "[path]", + options: [{ flags: "--json", description: "machine-readable scan result" }], async run(ctx: CommandContext): Promise { const { reporter, services } = ctx; const root = resolve(ctx.cwd, ctx.args[0] ?? "."); - const architecture = await detectArchitecture(root, services); - if (architecture === undefined) { + const architecture = await scanArchitecture(root, services); + if (ctx.flags["json"] === true) { + reporter.info(JSON.stringify(architecture, null, 2)); + await saveReport(architecture, services, reporter, false); + return OK; + } + if (architecture.analyzers.length === 0) { reporter.warn(`No applicable analyzers for ${root}.`); reporter.info("Supported signals: dependency manifests (package.json, requirements.txt, pyproject.toml, go.mod, Cargo.toml)."); return OK; } reportConcepts(architecture.concepts, reporter); - await saveReport(architecture, services, reporter); + await saveReport(architecture, services, reporter, true); return OK; }, @@ -49,20 +56,45 @@ export const analyzeCommand: Command = { export async function detectArchitecture( root: string, services: Services, -): Promise { +): Promise { + const result = await scanArchitecture(root, services); + return result.analyzers.length === 0 ? undefined : result; +} + +export async function scanArchitecture( + root: string, + services: Services, +): Promise { const context: AnalyzerContext = { root }; const applicable = await services.analyzers.applicable(context); - if (applicable.length === 0) return undefined; const detections: DetectedConcept[] = []; for (const analyzer of applicable) { detections.push(...(await analyzer.analyze(context))); } + const inspected: string[] = []; + for (const file of SUPPORTED_MANIFEST_FILES) { + if (await services.storage.exists(join(root, file))) inspected.push(file); + } + return { + schemaVersion: 1, + kind: "map.scan-result", root, detectedAt: new Date().toISOString(), - concepts: mergeConcepts(detections), + analyzers: applicable.map((analyzer) => analyzer.id).sort(), + inspected: inspected.sort(), + concepts: mergeConcepts(detections).map((detection) => ({ + ...detection, + certainty: certaintyForConfidence(detection.confidence), + })), + limitations: [ + applicable.length === 0 + ? "No analyzer supports the files at this project root; architecture is unknown." + : "Dependency manifests indicate declared packages, not whether or how code uses them.", + "The MVP scanner does not inspect source code, runtime behavior, nested workspaces, or secret values.", + ], }; } @@ -81,14 +113,16 @@ function reportConcepts( CONCEPTS.find((concept) => concept.id === detection.concept)?.name ?? detection.concept; const confidence = `${Math.round(detection.confidence * 100)}%`; - reporter.info(` ${name} (${confidence}) — ${detection.evidence.join(", ")}`); + const certainty = "certainty" in detection ? `, ${String(detection.certainty)}` : ""; + reporter.info(` ${name} (${confidence}${certainty}) — ${detection.evidence.join(", ")}`); } } async function saveReport( - architecture: DetectedArchitecture, + architecture: ScanResult, services: Services, reporter: Reporter, + announce: boolean, ): Promise { const { storage } = services; const mapDir = join(architecture.root, MAP_DIR); @@ -99,5 +133,12 @@ async function saveReport( await storage.writeFile(path, `${JSON.stringify(architecture, null, 2)}\n`, { overwrite: true, }); - reporter.success(`Report saved to ${path}`); + if (announce) reporter.success(`Report saved to ${path}`); } + +export const scanCommand: Command = { + ...analyzeCommand, + name: "scan", + summary: "Scan the project and return evidence-backed AI architecture signals.", + usage: "map scan [path] [--json]", +}; diff --git a/tooling/packages/cli/src/cli/commands/explain.ts b/tooling/packages/cli/src/cli/commands/explain.ts index c2727b2..c6f5f98 100644 --- a/tooling/packages/cli/src/cli/commands/explain.ts +++ b/tooling/packages/cli/src/cli/commands/explain.ts @@ -17,6 +17,7 @@ export const explainCommand: Command = { summary: "Explain a pattern: what it is, when to use it, trade-offs.", usage: "map explain ", args: "", + options: [{ flags: "--json", description: "machine-readable pattern metadata" }], async run(ctx: CommandContext): Promise { const { reporter, services } = ctx; @@ -45,11 +46,23 @@ export const explainCommand: Command = { return FAILED; } - render(ctx, entry); + if (ctx.flags["json"] === true) { + const { files: _files, ...metadata } = entry; + reporter.info(JSON.stringify(metadata, null, 2)); + } else { + render(ctx, entry); + } return OK; }, }; +export const showCommand: Command = { + ...explainCommand, + name: "show", + summary: "Show one pattern, its trade-offs, score, and relationships.", + usage: "map show [--json]", +}; + function render(ctx: CommandContext, entry: CatalogEntry): void { const { reporter } = ctx; diff --git a/tooling/packages/cli/src/cli/commands/graph.ts b/tooling/packages/cli/src/cli/commands/graph.ts new file mode 100644 index 0000000..3861dad --- /dev/null +++ b/tooling/packages/cli/src/cli/commands/graph.ts @@ -0,0 +1,36 @@ +import type { Command, CommandContext, CommandResult } from "../command.ts"; +import { FAILED, OK } from "../command.ts"; +import { buildPatternGraph } from "../../graph/index.ts"; + +export const graphCommand: Command = { + name: "graph", + summary: "Inspect pattern nodes and typed relationships.", + usage: "map graph [pattern-id] [--json]", + args: "[pattern-id]", + options: [{ flags: "--json", description: "machine-readable graph projection" }], + + async run(ctx: CommandContext): Promise { + const graph = buildPatternGraph(await ctx.services.catalog.entries()); + const id = ctx.args[0]; + if (id !== undefined && !graph.hasNode(id)) { + ctx.reporter.error(`Unknown pattern id '${id}'.`); + return FAILED; + } + const nodes = id === undefined ? [...graph.nodes()].sort() : [id]; + const edges = (id === undefined ? graph.edges() : graph.neighbors(id)).slice().sort( + (a, b) => a.from.localeCompare(b.from) || a.type.localeCompare(b.type) || a.to.localeCompare(b.to), + ); + if (ctx.flags["json"] === true) { + ctx.reporter.info(JSON.stringify({ schemaVersion: 1, kind: "map.pattern-graph", nodes, edges }, null, 2)); + return OK; + } + if (id === undefined) { + ctx.reporter.info(`Pattern graph: ${nodes.length} node(s), ${edges.length} edge(s).`); + ctx.reporter.info("Inspect one node with 'map graph '."); + return OK; + } + ctx.reporter.info(`${id}: ${edges.length} outgoing relationship(s)`); + for (const edge of edges) ctx.reporter.info(` ${edge.type} -> ${edge.to}${edge.note ? ` — ${edge.note}` : ""}`); + return OK; + }, +}; diff --git a/tooling/packages/cli/src/cli/commands/index.ts b/tooling/packages/cli/src/cli/commands/index.ts index e3eb40c..d3677dd 100644 --- a/tooling/packages/cli/src/cli/commands/index.ts +++ b/tooling/packages/cli/src/cli/commands/index.ts @@ -1,16 +1,18 @@ /** * Registers the built-in command set. `init`, `add`, `sync`, `watch`, `explain`, - * `analyze`, `recommend`, `patterns`, `doctor`, and `update` are implemented; the - * rest are scaffolded placeholders for future modules. + * `analyze`, `recommend`, `patterns`, graph, validation, compatible workflow aliases, + * doctor, and update are implemented; architecture diff remains a placeholder. */ import type { CommandRegistry } from "../command-registry.ts"; import { initCommand } from "./init.ts"; import { addCommand } from "./add.ts"; -import { explainCommand } from "./explain.ts"; -import { analyzeCommand } from "./analyze.ts"; -import { recommendCommand } from "./recommend.ts"; -import { patternsCommand } from "./patterns.ts"; +import { explainCommand, showCommand } from "./explain.ts"; +import { analyzeCommand, scanCommand } from "./analyze.ts"; +import { recommendCommand, suggestCommand } from "./recommend.ts"; +import { listCommand, patternsCommand, searchCommand } from "./patterns.ts"; +import { graphCommand } from "./graph.ts"; +import { validateCommand } from "./validate.ts"; import { doctorCommand } from "./doctor.ts"; import { updateCommand } from "./update.ts"; import { syncCommand } from "./sync.ts"; @@ -24,20 +26,20 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(syncCommand); registry.register(watchCommand); registry.register(explainCommand); + registry.register(showCommand); registry.register(analyzeCommand); + registry.register(scanCommand); registry.register(recommendCommand); + registry.register(suggestCommand); registry.register(patternsCommand); + registry.register(listCommand); + registry.register(searchCommand); + registry.register(graphCommand); + registry.register(validateCommand); registry.register(doctorCommand); registry.register(updateCommand); registry.register(optimizeCommand); - registry.register( - planned({ - name: "graph", - summary: "Build and inspect the pattern graph.", - module: "Module 4 — Graph", - }), - ); registry.register( planned({ name: "diff", diff --git a/tooling/packages/cli/src/cli/commands/patterns.ts b/tooling/packages/cli/src/cli/commands/patterns.ts index b9ec140..8802c22 100644 --- a/tooling/packages/cli/src/cli/commands/patterns.ts +++ b/tooling/packages/cli/src/cli/commands/patterns.ts @@ -90,6 +90,20 @@ export const patternsCommand: Command = { }, }; +export const listCommand: Command = { + ...patternsCommand, + name: "list", + summary: "List patterns in the MAP catalog.", + usage: "map list [--category ] [--status ] [--json]", +}; + +export const searchCommand: Command = { + ...patternsCommand, + name: "search", + summary: "Search patterns by id, name, or summary.", + usage: "map search [text] [--category ] [--status ] [--json]", +}; + function optionalString(value: string | boolean | undefined): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; } diff --git a/tooling/packages/cli/src/cli/commands/recommend.ts b/tooling/packages/cli/src/cli/commands/recommend.ts index 8aa6ff0..590b5aa 100644 --- a/tooling/packages/cli/src/cli/commands/recommend.ts +++ b/tooling/packages/cli/src/cli/commands/recommend.ts @@ -10,12 +10,18 @@ import { resolve } from "node:path"; import type { Command, CommandContext, CommandResult } from "../command.ts"; import { OK } from "../command.ts"; import { detectArchitecture } from "./analyze.ts"; +import type { RecommendationResult } from "../../domain/index.ts"; + +const RECOMMENDATION_LIMITATIONS = [ + "Recommendations infer review candidates from static signals; they do not prove a pattern is absent or suitable.", +] as const; export const recommendCommand: Command = { name: "recommend", summary: "Recommend patterns missing from the detected architecture.", usage: "map recommend [path]", args: "[path]", + options: [{ flags: "--json", description: "machine-readable recommendation result" }], async run(ctx: CommandContext): Promise { const { reporter, services } = ctx; @@ -23,15 +29,25 @@ export const recommendCommand: Command = { const architecture = await detectArchitecture(root, services); if (architecture === undefined || architecture.concepts.length === 0) { + if (ctx.flags["json"] === true) { + const detectedAt = architecture?.detectedAt ?? new Date().toISOString(); + reporter.info(JSON.stringify(result(root, detectedAt, []), null, 2)); + return OK; + } reporter.info("No AI usage detected — nothing to recommend."); reporter.info("Run 'map analyze' to see what MAP looks for."); return OK; } + const recommendations = await services.recommender.recommend(architecture); + if (ctx.flags["json"] === true) { + reporter.info( + JSON.stringify(result(root, architecture.detectedAt, recommendations), null, 2), + ); + return OK; + } const detected = architecture.concepts.map((c) => c.concept).join(", "); reporter.info(`Detected: ${detected}`); - - const recommendations = await services.recommender.recommend(architecture); if (recommendations.length === 0) { reporter.success("No gaps found for the detected architecture."); return OK; @@ -51,3 +67,24 @@ export const recommendCommand: Command = { return OK; }, }; + +function result( + root: string, + detectedAt: string, + recommendations: RecommendationResult["recommendations"], +): RecommendationResult { + return { + schemaVersion: 1, + kind: "map.recommendation-result", + scan: { root, detectedAt }, + recommendations, + limitations: RECOMMENDATION_LIMITATIONS, + }; +} + +export const suggestCommand: Command = { + ...recommendCommand, + name: "suggest", + summary: "Suggest evidence-backed patterns for the detected architecture.", + usage: "map suggest [path] [--json]", +}; diff --git a/tooling/packages/cli/src/cli/commands/validate.ts b/tooling/packages/cli/src/cli/commands/validate.ts new file mode 100644 index 0000000..13748e4 --- /dev/null +++ b/tooling/packages/cli/src/cli/commands/validate.ts @@ -0,0 +1,73 @@ +import { join } from "node:path"; +import type { Command, CommandContext, CommandResult } from "../command.ts"; +import { FAILED, OK } from "../command.ts"; +import { CONFIG_FILE, MAP_DIR, parseConfig } from "../../config/index.ts"; + +interface ValidationReport { + readonly schemaVersion: 1; + readonly kind: "map.validation-result"; + readonly valid: boolean; + readonly checked: readonly string[]; + readonly errors: readonly string[]; +} + +export const validateCommand: Command = { + name: "validate", + summary: "Validate the MAP workspace and adopted pattern metadata.", + usage: "map validate [--json]", + options: [{ flags: "--json", description: "machine-readable validation result" }], + + async run(ctx: CommandContext): Promise { + const checked: string[] = []; + const errors: string[] = []; + const mapDir = join(ctx.cwd, MAP_DIR); + const configPath = join(mapDir, CONFIG_FILE); + if (!(await ctx.services.storage.exists(configPath))) { + errors.push(`${MAP_DIR}/${CONFIG_FILE} is missing; run 'map init'.`); + } else { + checked.push(`${MAP_DIR}/${CONFIG_FILE}`); + try { + parseConfig(await ctx.services.storage.readFile(configPath)); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + + const patternsRoot = join(mapDir, "patterns"); + for (const relative of await ctx.services.storage.listFiles(patternsRoot)) { + if (!relative.endsWith("pattern.json")) continue; + const display = `${MAP_DIR}/patterns/${relative.split("\\").join("/")}`; + checked.push(display); + try { + const value: unknown = JSON.parse(await ctx.services.storage.readFile(join(patternsRoot, relative))); + if (!isRecord(value) || typeof value.id !== "string") throw new Error(`${display}: id is required`); + const expected = relative.split(/[\\/]/).slice(0, -1).join("/"); + if (value.id !== expected) throw new Error(`${display}: id '${value.id}' must match '${expected}'`); + if ((await ctx.services.catalog.get(value.id)) === undefined) throw new Error(`${display}: unknown catalog id '${value.id}'`); + for (const file of ["prompt.md", "acceptance.md"]) { + if (!(await ctx.services.storage.exists(join(patternsRoot, expected, file)))) { + throw new Error(`${display}: adopted pattern is missing ${file}`); + } + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + + const report: ValidationReport = { + schemaVersion: 1, + kind: "map.validation-result", + valid: errors.length === 0, + checked: checked.sort(), + errors, + }; + if (ctx.flags["json"] === true) ctx.reporter.info(JSON.stringify(report, null, 2)); + else if (report.valid) ctx.reporter.success(`MAP contracts valid (${checked.length} artifact(s) checked).`); + else errors.forEach((error) => ctx.reporter.error(error)); + return report.valid ? OK : FAILED; + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tooling/packages/cli/src/domain/analysis.ts b/tooling/packages/cli/src/domain/analysis.ts index cd0c3d1..2bb38cd 100644 --- a/tooling/packages/cli/src/domain/analysis.ts +++ b/tooling/packages/cli/src/domain/analysis.ts @@ -21,3 +21,25 @@ export interface DetectedArchitecture { readonly detectedAt: string; readonly concepts: readonly DetectedConcept[]; } + +export type DetectionCertainty = "detected" | "likely" | "unknown"; + +export interface ScanDetectedConcept extends DetectedConcept { + readonly certainty: DetectionCertainty; +} + +/** Stable JSON envelope emitted by `map scan --json`. */ +export interface ScanResult extends Omit { + readonly schemaVersion: 1; + readonly kind: "map.scan-result"; + readonly analyzers: readonly string[]; + readonly inspected: readonly string[]; + readonly concepts: readonly ScanDetectedConcept[]; + readonly limitations: readonly string[]; +} + +export function certaintyForConfidence(confidence: number): DetectionCertainty { + if (confidence >= 0.85) return "detected"; + if (confidence >= 0.6) return "likely"; + return "unknown"; +} diff --git a/tooling/packages/cli/src/domain/index.ts b/tooling/packages/cli/src/domain/index.ts index 1e468c3..6577ba4 100644 --- a/tooling/packages/cli/src/domain/index.ts +++ b/tooling/packages/cli/src/domain/index.ts @@ -12,8 +12,16 @@ export type { CatalogEntry, CatalogStatus, MapScore } from "./catalog.ts"; export type { Relationship, RelationshipType } from "./relationship.ts"; export type { ConceptId, ConceptDefinition } from "./concept.ts"; export { CONCEPTS } from "./concept.ts"; -export type { DetectedConcept, DetectedArchitecture } from "./analysis.ts"; +export type { + DetectedConcept, + DetectedArchitecture, + DetectionCertainty, + ScanDetectedConcept, + ScanResult, +} from "./analysis.ts"; +export { certaintyForConfidence } from "./analysis.ts"; export type { Recommendation, RecommendationPriority, + RecommendationResult, } from "./recommendation.ts"; diff --git a/tooling/packages/cli/src/domain/recommendation.ts b/tooling/packages/cli/src/domain/recommendation.ts index 1d69f13..7bb0a9f 100644 --- a/tooling/packages/cli/src/domain/recommendation.ts +++ b/tooling/packages/cli/src/domain/recommendation.ts @@ -16,3 +16,14 @@ export interface Recommendation { /** The detected concepts that triggered this recommendation. */ readonly triggeredBy: readonly ConceptId[]; } + +export interface RecommendationResult { + readonly schemaVersion: 1; + readonly kind: "map.recommendation-result"; + readonly scan: { + readonly root: string; + readonly detectedAt: string; + }; + readonly recommendations: readonly Recommendation[]; + readonly limitations: readonly string[]; +} diff --git a/tooling/packages/cli/src/graph/index.ts b/tooling/packages/cli/src/graph/index.ts index e302e7f..8190582 100644 --- a/tooling/packages/cli/src/graph/index.ts +++ b/tooling/packages/cli/src/graph/index.ts @@ -1,2 +1,2 @@ export type { PatternGraph } from "./pattern-graph.ts"; -export { InMemoryPatternGraph } from "./pattern-graph.ts"; +export { InMemoryPatternGraph, buildPatternGraph } from "./pattern-graph.ts"; diff --git a/tooling/packages/cli/src/graph/pattern-graph.ts b/tooling/packages/cli/src/graph/pattern-graph.ts index fad53ae..4d52419 100644 --- a/tooling/packages/cli/src/graph/pattern-graph.ts +++ b/tooling/packages/cli/src/graph/pattern-graph.ts @@ -7,6 +7,7 @@ */ import type { + CatalogEntry, PatternId, Relationship, RelationshipType, @@ -22,6 +23,20 @@ export interface PatternGraph { neighbors(id: PatternId, type?: RelationshipType): readonly Relationship[]; } +/** Build the v1 graph projection; legacy `related` edges mean `works_with`. */ +export function buildPatternGraph(entries: readonly CatalogEntry[]): PatternGraph { + const graph = new InMemoryPatternGraph(); + for (const entry of [...entries].sort((a, b) => a.id.localeCompare(b.id))) { + graph.addNode(entry.id); + } + for (const entry of [...entries].sort((a, b) => a.id.localeCompare(b.id))) { + for (const target of [...(entry.related ?? [])].sort()) { + graph.addEdge({ from: entry.id, type: "works_with", to: target }); + } + } + return graph; +} + export class InMemoryPatternGraph implements PatternGraph { private readonly nodeSet = new Set(); private readonly edgeList: Relationship[] = []; diff --git a/tooling/packages/cli/tests/analyze-command.test.ts b/tooling/packages/cli/tests/analyze-command.test.ts index 5062e00..561a8ec 100644 --- a/tooling/packages/cli/tests/analyze-command.test.ts +++ b/tooling/packages/cli/tests/analyze-command.test.ts @@ -61,6 +61,7 @@ describe("map analyze", () => { { concept: "vector_search", confidence: 0.9, + certainty: "detected", evidence: ["package.json: chromadb"], }, ]); diff --git a/tooling/packages/cli/tests/mvp-commands.test.ts b/tooling/packages/cli/tests/mvp-commands.test.ts new file mode 100644 index 0000000..74afc94 --- /dev/null +++ b/tooling/packages/cli/tests/mvp-commands.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli/runner.ts"; +import { capture } from "./helpers.ts"; + +async function project(): Promise { + const root = await mkdtemp(join(tmpdir(), "map-mvp-")); + await writeFile(join(root, "package.json"), JSON.stringify({ dependencies: { langchain: "^1" } })); + return root; +} + +describe("deterministic MVP commands", () => { + it("emits a versioned scan result through the new alias", async () => { + const root = await project(); + try { + const reporter = capture(); + expect(await runCli(["scan", "--json"], { cwd: root, reporter })).toBe(0); + const result = JSON.parse(reporter.lines[0]!); + expect(result).toMatchObject({ schemaVersion: 1, kind: "map.scan-result" }); + expect(result.analyzers).toEqual(["dependency-manifest"]); + expect(result.inspected).toEqual(["package.json"]); + expect(result.concepts[0].certainty).toBe("likely"); + expect(result.limitations).not.toHaveLength(0); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("emits explainable suggestions through the new alias", async () => { + const root = await project(); + try { + const reporter = capture(); + expect(await runCli(["suggest", "--json"], { cwd: root, reporter })).toBe(0); + const result = JSON.parse(reporter.lines[0]!); + expect(result.kind).toBe("map.recommendation-result"); + expect(result.recommendations[0]).toEqual(expect.objectContaining({ + pattern: expect.any(String), rationale: expect.any(String), triggeredBy: expect.any(Array), + })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("supports list, search, and show without removing the original commands", async () => { + const listed = capture(); + expect(await runCli(["list", "--status", "published", "--json"], { reporter: listed })).toBe(0); + expect(JSON.parse(listed.lines[0]!)).toHaveLength(5); + + const searched = capture(); + expect(await runCli(["search", "chunk", "--json"], { reporter: searched })).toBe(0); + expect(JSON.parse(searched.lines[0]!)[0].id).toBe("retrieval/chunking"); + + const shown = capture(); + expect(await runCli(["show", "retrieval/chunking", "--json"], { reporter: shown })).toBe(0); + expect(JSON.parse(shown.lines[0]!)).toMatchObject({ id: "retrieval/chunking" }); + }); + + it("projects the catalog as graph nodes and typed edges", async () => { + const reporter = capture(); + expect(await runCli(["graph", "retrieval/chunking", "--json"], { reporter })).toBe(0); + const result = JSON.parse(reporter.lines[0]!); + expect(result).toMatchObject({ schemaVersion: 1, kind: "map.pattern-graph", nodes: ["retrieval/chunking"] }); + expect(result.edges.every((edge: { type: string }) => edge.type === "works_with")).toBe(true); + }); + + it("validates an initialized workspace and adopted pattern", async () => { + const root = await project(); + try { + expect(await runCli(["init"], { cwd: root, reporter: capture() })).toBe(0); + expect(await runCli(["add", "retrieval/chunking"], { cwd: root, reporter: capture() })).toBe(0); + const reporter = capture(); + expect(await runCli(["validate", "--json"], { cwd: root, reporter })).toBe(0); + expect(JSON.parse(reporter.lines[0]!)).toMatchObject({ valid: true, kind: "map.validation-result" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tooling/packages/cli/tests/runner.test.ts b/tooling/packages/cli/tests/runner.test.ts index d585859..5cd2ba2 100644 --- a/tooling/packages/cli/tests/runner.test.ts +++ b/tooling/packages/cli/tests/runner.test.ts @@ -85,10 +85,10 @@ describe("runCli", () => { expect(reporter.lines.join("\n")).toContain("does a thing"); }); - it("runs the built-in planned commands and warns", async () => { + it("runs the built-in graph command", async () => { const reporter = capture(); const code = await runCli(["graph"], { reporter }); expect(code).toBe(0); - expect(reporter.lines.join("\n")).toContain("planned"); + expect(reporter.lines.join("\n")).toContain("96 node(s)"); }); });