@topogram/cli
Advanced tools
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { stablePublicStringify } from "../../public-paths.js"; | ||
| import { resolveTopoRoot } from "../../workspace-paths.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function humanize(value) { | ||
| return String(value || "") | ||
| .replace(/[_-]+/g, " ") | ||
| .replace(/\b\w/g, (match) => match.toUpperCase()); | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function tgString(value) { | ||
| return JSON.stringify(String(value || "")); | ||
| } | ||
| /** | ||
| * @param {string} slug | ||
| * @param {string|null|undefined} intent | ||
| * @returns {string} | ||
| */ | ||
| function featureSource(slug, intent) { | ||
| return `feature feature_${slug} { | ||
| name ${tgString(humanize(slug))} | ||
| description "Describe the user-visible product capability." | ||
| intent ${tgString(intent || "Describe the outcome this feature should make possible.")} | ||
| entities [] | ||
| capabilities [] | ||
| endpoints [] | ||
| seed_data [] | ||
| verification_refs [] | ||
| status draft | ||
| } | ||
| `; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * commandArgs: AnyRecord, | ||
| * inputPath: string|null|undefined, | ||
| * intent?: string|null, | ||
| * write?: boolean, | ||
| * json?: boolean, | ||
| * cwd?: string | ||
| * }} options | ||
| * @returns {Promise<number>} | ||
| */ | ||
| export async function runFeatureCommand(options) { | ||
| if (options.commandArgs.featureCommand !== "new") { | ||
| console.error("Unsupported feature command. Use: topogram feature new <slug> [path] [--intent <text>] [--write] [--json]"); | ||
| return 2; | ||
| } | ||
| const slug = String(options.commandArgs.featureSlug || ""); | ||
| if (!/^[a-z][a-z0-9_]*$/.test(slug)) { | ||
| const payload = { | ||
| type: "feature_new_result", | ||
| ok: false, | ||
| error: `Invalid feature slug '${slug}' - must match /^[a-z][a-z0-9_]*$/` | ||
| }; | ||
| if (options.json) { | ||
| console.log(stablePublicStringify(payload, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| console.error(payload.error); | ||
| } | ||
| return 1; | ||
| } | ||
| const topogramRoot = resolveTopoRoot(options.inputPath || "."); | ||
| const projectRoot = path.basename(topogramRoot) === "topo" ? path.dirname(topogramRoot) : topogramRoot; | ||
| const relativeFile = path.posix.join("topo", "features", `${slug}.tg`); | ||
| const targetFile = path.join(projectRoot, relativeFile); | ||
| const source = featureSource(slug, options.intent || null); | ||
| if (fs.existsSync(targetFile)) { | ||
| const payload = { | ||
| type: "feature_new_result", | ||
| ok: false, | ||
| feature_id: `feature_${slug}`, | ||
| file: relativeFile, | ||
| write: Boolean(options.write), | ||
| error: `Refusing to overwrite '${relativeFile}'` | ||
| }; | ||
| if (options.json) console.log(stablePublicStringify(payload, { projectRoot, workspaceRoot: topogramRoot, cwd: options.cwd || process.cwd() })); | ||
| else console.error(payload.error); | ||
| return 1; | ||
| } | ||
| if (options.write) { | ||
| fs.mkdirSync(path.dirname(targetFile), { recursive: true }); | ||
| fs.writeFileSync(targetFile, source, "utf8"); | ||
| } | ||
| const payload = { | ||
| type: "feature_new_result", | ||
| ok: true, | ||
| feature_id: `feature_${slug}`, | ||
| slug, | ||
| file: relativeFile, | ||
| write: Boolean(options.write), | ||
| source, | ||
| next_commands: [ | ||
| `topogram check ${path.basename(topogramRoot) === "topo" ? "./topo" : "."} --json`, | ||
| `topogram sdlc new task ${slug} . --feature feature_${slug} --phase implementation --scope current_feature --write` | ||
| ] | ||
| }; | ||
| if (options.json) { | ||
| console.log(stablePublicStringify(payload, { projectRoot, workspaceRoot: topogramRoot, cwd: options.cwd || process.cwd() })); | ||
| } else { | ||
| console.log(`${options.write ? "Created" : "Would create"} ${relativeFile}`); | ||
| console.log(`Feature: feature_${slug}`); | ||
| if (!options.write) console.log("Use --write to create the file."); | ||
| } | ||
| return 0; | ||
| } |
| // @ts-check | ||
| import childProcess from "node:child_process"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { buildOutputFiles, generateWorkspace } from "../../generator.js"; | ||
| import { safeJoinRelativePath } from "../../package-adapters/index.js"; | ||
| import { stablePublicStringify, sanitizePublicPayload, toPortablePath } from "../../public-paths.js"; | ||
| import { formatValidationErrors } from "../../validator.js"; | ||
| import { resolveWorkspaceContext } from "../../workspace-paths.js"; | ||
| import { buildCheckCommandPayload } from "./check.js"; | ||
| import { writeGeneratedAppBundle } from "./generate.js"; | ||
| import { topogramInputPathForGeneration } from "../output-safety.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {string} id | ||
| * @param {string} status | ||
| * @param {string} summary | ||
| * @param {string|null} nextCommand | ||
| * @param {boolean} blocking | ||
| * @param {string|null} artifactPath | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function stage(id, status, summary, nextCommand = null, blocking = false, artifactPath = null) { | ||
| return { | ||
| id, | ||
| status, | ||
| summary, | ||
| next_command: nextCommand, | ||
| blocking, | ||
| ...(artifactPath ? { artifact_path: artifactPath } : {}) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} command | ||
| * @param {string|null|undefined} suffix | ||
| * @returns {string} | ||
| */ | ||
| function withPath(command, suffix) { | ||
| return suffix && suffix !== "." ? `${command} ${suffix}` : command; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} projectConfig | ||
| * @returns {boolean} | ||
| */ | ||
| function hasGeneratedOwnedOutput(projectConfig) { | ||
| return Object.values(projectConfig?.outputs || {}).some((output) => | ||
| output && typeof output === "object" && /** @type {AnyRecord} */ (output).ownership === "generated" | ||
| ); | ||
| } | ||
| /** | ||
| * @param {string} projectRoot | ||
| * @returns {string|null} | ||
| */ | ||
| function selectVerifyScript(projectRoot) { | ||
| const packagePath = path.join(projectRoot, "package.json"); | ||
| if (!fs.existsSync(packagePath)) { | ||
| return null; | ||
| } | ||
| let pkg; | ||
| try { | ||
| pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| const scripts = pkg?.scripts || {}; | ||
| if (scripts.verify) return "verify"; | ||
| if (scripts["app:compile"]) return "app:compile"; | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {string} projectRoot | ||
| * @param {string} script | ||
| * @returns {{ ok: boolean, exitCode: number|null, command: string }} | ||
| */ | ||
| function runVerifyScript(projectRoot, script) { | ||
| const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; | ||
| const result = childProcess.spawnSync(npmBin, ["run", script], { | ||
| cwd: projectRoot, | ||
| encoding: "utf8", | ||
| stdio: "pipe", | ||
| env: { | ||
| ...process.env, | ||
| PATH: process.env.PATH || "" | ||
| } | ||
| }); | ||
| return { | ||
| ok: result.status === 0, | ||
| exitCode: result.status, | ||
| command: `npm run ${script}` | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {string} | ||
| */ | ||
| function auditProfile(options) { | ||
| if (options.bugId) return "bug"; | ||
| if (options.taskId) return "standard"; | ||
| return "adoption"; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * ast: AnyRecord, | ||
| * topogramRoot: string, | ||
| * projectRoot: string, | ||
| * cwd: string, | ||
| * outDir: string, | ||
| * taskId?: string|null, | ||
| * bugId?: string|null | ||
| * }} options | ||
| * @returns {{ filesWritten: number, manifestPath: string }} | ||
| */ | ||
| function writeAuditBundle(options) { | ||
| const profileId = auditProfile(options); | ||
| const result = generateWorkspace(options.ast, { | ||
| target: "audit-bundle", | ||
| profileId, | ||
| taskId: options.taskId, | ||
| bugId: options.bugId, | ||
| topogramInputPath: topogramInputPathForGeneration(options.topogramRoot), | ||
| inputPath: options.topogramRoot, | ||
| workspaceRoot: options.topogramRoot, | ||
| projectRoot: options.projectRoot, | ||
| configDir: options.projectRoot, | ||
| cwd: options.cwd | ||
| }); | ||
| if (!result.ok) { | ||
| throw new Error(formatValidationErrors(result.validation)); | ||
| } | ||
| const outputFiles = /** @type {Array<{ path: string, contents: any }>} */ (buildOutputFiles(result, { | ||
| projectRoot: options.projectRoot, | ||
| workspaceRoot: options.topogramRoot, | ||
| inputPath: options.topogramRoot, | ||
| cwd: options.cwd | ||
| })); | ||
| fs.mkdirSync(options.outDir, { recursive: true }); | ||
| for (const file of outputFiles) { | ||
| const destination = safeJoinRelativePath(options.outDir, file.path, `Refusing unsafe artifact output path '${file.path}'.`); | ||
| const relativeToInput = path.relative(path.resolve(options.topogramRoot), path.resolve(destination)); | ||
| if (relativeToInput === "" || (!relativeToInput.startsWith("..") && !path.isAbsolute(relativeToInput))) { | ||
| throw new Error(`Refusing to write artifact inside the Topogram source directory: ${toPortablePath(destination, { | ||
| projectRoot: options.projectRoot, | ||
| workspaceRoot: options.topogramRoot, | ||
| cwd: options.cwd | ||
| })}`); | ||
| } | ||
| fs.mkdirSync(path.dirname(destination), { recursive: true }); | ||
| const contents = typeof file.contents === "string" | ||
| ? file.contents | ||
| : `${stablePublicStringify(file.contents, { | ||
| projectRoot: options.projectRoot, | ||
| workspaceRoot: options.topogramRoot, | ||
| cwd: options.cwd | ||
| })}\n`; | ||
| fs.writeFileSync(destination, contents, "utf8"); | ||
| } | ||
| const manifest = outputFiles.find((file) => file.path.endsWith("audit-manifest.json")); | ||
| return { | ||
| filesWritten: outputFiles.length, | ||
| manifestPath: manifest ? path.join(options.outDir, manifest.path) : options.outDir | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} plan | ||
| * @returns {void} | ||
| */ | ||
| function printOnboardingPlan(plan) { | ||
| console.log(`Topogram onboard: ${plan.status}`); | ||
| console.log(`Project: ${plan.project_root}`); | ||
| console.log(`Workspace: ${plan.topogram_root}`); | ||
| console.log(""); | ||
| console.log("Stages:"); | ||
| for (const item of plan.stages) { | ||
| const next = item.next_command ? ` next: ${item.next_command}` : ""; | ||
| const artifact = item.artifact_path ? ` artifact: ${item.artifact_path}` : ""; | ||
| console.log(`- ${item.id}: ${item.status} - ${item.summary}${next}${artifact}`); | ||
| } | ||
| if (plan.recommended_commands.length > 0) { | ||
| console.log(""); | ||
| console.log("Recommended commands:"); | ||
| for (const command of plan.recommended_commands) { | ||
| console.log(`- ${command}`); | ||
| } | ||
| } | ||
| if (plan.caveats.length > 0) { | ||
| console.log(""); | ||
| console.log("Caveats:"); | ||
| for (const caveat of plan.caveats) { | ||
| console.log(`- ${caveat}`); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} stages | ||
| * @returns {string} | ||
| */ | ||
| function overallStatus(stages) { | ||
| if (stages.some((item) => item.id === "init" && item.status === "needed")) return "needs_init"; | ||
| if (stages.some((item) => item.blocking || item.status === "failed")) return "blocked"; | ||
| if (stages.some((item) => item.id === "verify" && item.status === "verified")) return "verified"; | ||
| if (stages.some((item) => item.id === "generate" && item.status === "generated")) return "generated"; | ||
| return "ready"; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * inputPath?: string|null, | ||
| * json?: boolean, | ||
| * write?: boolean, | ||
| * outDir?: string|null, | ||
| * taskId?: string|null, | ||
| * bugId?: string|null, | ||
| * generate?: boolean, | ||
| * runVerify?: boolean, | ||
| * strict?: boolean, | ||
| * cwd?: string | ||
| * }} options | ||
| * @returns {Promise<number>} | ||
| */ | ||
| export async function runOnboardCommand(options = {}) { | ||
| const cwd = options.cwd || process.cwd(); | ||
| const requestedPath = options.inputPath || "."; | ||
| const resolution = resolveWorkspaceContext(requestedPath); | ||
| const publicContext = { | ||
| projectRoot: resolution.projectRoot, | ||
| workspaceRoot: resolution.topoRoot, | ||
| topogramRoot: resolution.topoRoot, | ||
| cwd | ||
| }; | ||
| const projectArg = toPortablePath(resolution.projectRoot, publicContext); | ||
| const topoArg = toPortablePath(resolution.topoRoot, publicContext); | ||
| /** @type {AnyRecord[]} */ | ||
| const stages = []; | ||
| /** @type {string[]} */ | ||
| const recommended = []; | ||
| /** @type {string[]} */ | ||
| const caveats = []; | ||
| if (resolution.bootstrappedTopoRoot || !fs.existsSync(resolution.topoRoot)) { | ||
| const initCommand = withPath("topogram init", projectArg) + " --adopt-sdlc"; | ||
| stages.push(stage("init", "needed", "No Topogram workspace was found.", initCommand, false)); | ||
| stages.push(stage("check", "blocked", "Check requires a Topogram workspace.", initCommand, true)); | ||
| stages.push(stage("audit_bundle", "blocked", "Audit bundle requires a valid Topogram workspace.", initCommand, true)); | ||
| stages.push(stage("generate", "blocked", "Generation requires a configured Topogram workspace.", initCommand, true)); | ||
| stages.push(stage("verify", "blocked", "Verification requires project scripts.", initCommand, true)); | ||
| recommended.push(initCommand); | ||
| const plan = { | ||
| type: "onboarding_plan", | ||
| version: 1, | ||
| status: "needs_init", | ||
| project_root: toPortablePath(resolution.projectRoot, publicContext), | ||
| topogram_root: toPortablePath(resolution.topoRoot, publicContext), | ||
| stages, | ||
| recommended_commands: recommended, | ||
| caveats | ||
| }; | ||
| const publicPlan = sanitizePublicPayload(plan, publicContext); | ||
| if (options.json) { | ||
| console.log(stablePublicStringify(publicPlan, publicContext)); | ||
| } else { | ||
| printOnboardingPlan(publicPlan); | ||
| } | ||
| return options.strict ? 1 : 0; | ||
| } | ||
| const check = await buildCheckCommandPayload(resolution.topoRoot); | ||
| const projectConfig = check.projectConfigInfo?.config || null; | ||
| const checkCommand = `topogram check ${topoArg} --json`; | ||
| const modelingGuideCommand = `topogram query modeling-guide ${topoArg} --format markdown`; | ||
| const workNextCommand = options.taskId | ||
| ? `topogram work next ${topoArg} --task ${options.taskId} --mode implementation --json` | ||
| : null; | ||
| const sparseOrInvalidModel = !check.payload.ok || Number(check.payload.topogram?.statements || 0) < 8; | ||
| stages.push(stage( | ||
| "init", | ||
| "complete", | ||
| "Topogram workspace is present.", | ||
| null, | ||
| false | ||
| )); | ||
| stages.push(check.payload.ok | ||
| ? stage("check", "passed", `Validated ${check.payload.topogram.files} file(s) and ${check.payload.topogram.statements} statement(s).`, checkCommand, false) | ||
| : stage("check", "failed", `${check.payload.errors.length} error(s), ${check.payload.warnings.length} warning(s).`, checkCommand, true)); | ||
| if (sparseOrInvalidModel) { | ||
| recommended.push(modelingGuideCommand); | ||
| caveats.push("Sparse or invalid Topogram workspaces should use modeling-guide's phase order before broad DSL authoring."); | ||
| } | ||
| if (workNextCommand) { | ||
| recommended.push(workNextCommand); | ||
| caveats.push("For task implementation, use work next before broad slice queries or app edits."); | ||
| } | ||
| const auditCommand = [ | ||
| "topogram onboard", | ||
| topoArg, | ||
| options.taskId ? `--task ${options.taskId}` : "", | ||
| options.bugId ? `--bug ${options.bugId}` : "", | ||
| "--write", | ||
| `--out-dir ${toPortablePath(path.resolve(options.outDir || "artifacts"), publicContext)}` | ||
| ].filter(Boolean).join(" "); | ||
| if (!check.payload.ok) { | ||
| stages.push(stage("audit_bundle", "blocked", "Audit bundle waits for a valid workspace.", checkCommand, true)); | ||
| } else if (options.write) { | ||
| try { | ||
| const writeResult = writeAuditBundle({ | ||
| ast: check.ast, | ||
| topogramRoot: resolution.topoRoot, | ||
| projectRoot: check.projectConfigInfo?.configDir || resolution.projectRoot, | ||
| cwd, | ||
| outDir: path.resolve(options.outDir || "artifacts"), | ||
| taskId: options.taskId, | ||
| bugId: options.bugId | ||
| }); | ||
| stages.push(stage( | ||
| "audit_bundle", | ||
| "written", | ||
| `Wrote ${writeResult.filesWritten} audit artifact(s).`, | ||
| null, | ||
| false, | ||
| toPortablePath(writeResult.manifestPath, publicContext) | ||
| )); | ||
| } catch (error) { | ||
| stages.push(stage("audit_bundle", "failed", error instanceof Error ? error.message : String(error), auditCommand, true)); | ||
| } | ||
| } else { | ||
| stages.push(stage("audit_bundle", "ready", "Audit evidence can be written on request.", auditCommand, false)); | ||
| recommended.push(auditCommand); | ||
| } | ||
| const generateConfigured = hasGeneratedOwnedOutput(projectConfig); | ||
| const generateCommand = `topogram onboard ${topoArg} --generate`; | ||
| let generationFailed = false; | ||
| if (!generateConfigured) { | ||
| stages.push(stage("generate", "not_applicable", "No generated-owned output is configured.", null, false)); | ||
| } else if (!check.payload.ok) { | ||
| stages.push(stage("generate", "blocked", "Generation waits for a passing check.", checkCommand, true)); | ||
| } else if (options.generate) { | ||
| try { | ||
| const generated = await writeGeneratedAppBundle({ | ||
| inputPath: resolution.topoRoot, | ||
| projectRoot: check.projectConfigInfo?.configDir || resolution.projectRoot, | ||
| profileId: null | ||
| }); | ||
| stages.push(stage( | ||
| "generate", | ||
| "generated", | ||
| `Wrote ${generated.filesWritten} generated app file(s).`, | ||
| null, | ||
| false, | ||
| toPortablePath(generated.outDir, publicContext) | ||
| )); | ||
| } catch (error) { | ||
| generationFailed = true; | ||
| stages.push(stage("generate", "failed", error instanceof Error ? error.message : String(error), generateCommand, true)); | ||
| } | ||
| } else { | ||
| stages.push(stage("generate", "ready", "Generated-owned output is configured.", generateCommand, false)); | ||
| recommended.push(generateCommand); | ||
| } | ||
| const verifyScript = selectVerifyScript(resolution.projectRoot); | ||
| const verifyCommand = verifyScript ? `topogram onboard ${projectArg} --run-verify` : null; | ||
| if (!verifyScript) { | ||
| stages.push(stage("verify", "not_applicable", "No package verify or app:compile script was found.", null, false)); | ||
| } else if (!check.payload.ok || generationFailed) { | ||
| stages.push(stage("verify", "blocked", "Verification waits for a passing check and generation.", verifyCommand, true)); | ||
| } else if (options.runVerify) { | ||
| const verification = runVerifyScript(resolution.projectRoot, verifyScript); | ||
| stages.push(stage( | ||
| "verify", | ||
| verification.ok ? "verified" : "failed", | ||
| verification.ok | ||
| ? `${verification.command} passed.` | ||
| : `${verification.command} exited ${verification.exitCode}.`, | ||
| verification.command, | ||
| !verification.ok | ||
| )); | ||
| } else { | ||
| stages.push(stage("verify", "ready", `Detected ${verifyScript} package script.`, verifyCommand, false)); | ||
| recommended.push(verifyCommand || `npm run ${verifyScript}`); | ||
| } | ||
| const status = overallStatus(stages); | ||
| const plan = { | ||
| type: "onboarding_plan", | ||
| version: 1, | ||
| status, | ||
| project_root: toPortablePath(resolution.projectRoot, publicContext), | ||
| topogram_root: toPortablePath(resolution.topoRoot, publicContext), | ||
| stages, | ||
| recommended_commands: [...new Set(recommended)], | ||
| caveats | ||
| }; | ||
| const publicPlan = sanitizePublicPayload(plan, publicContext); | ||
| if (options.json) { | ||
| console.log(stablePublicStringify(publicPlan, publicContext)); | ||
| } else { | ||
| printOnboardingPlan(publicPlan); | ||
| } | ||
| const operationRequested = Boolean(options.write || options.generate || options.runVerify); | ||
| const operationFailed = operationRequested && stages.some((item) => item.status === "failed"); | ||
| const strictFailure = Boolean(options.strict && (status === "blocked" || status === "needs_init")); | ||
| return operationFailed || strictFailure ? 1 : 0; | ||
| } | ||
| /** | ||
| * @returns {void} | ||
| */ | ||
| export function printOnboardHelp() { | ||
| console.log("Usage: topogram onboard [path] [--json]"); | ||
| console.log(" or: topogram onboard [path] --strict --json"); | ||
| console.log(" or: topogram onboard [path] --write [--out-dir <path>]"); | ||
| console.log(" or: topogram onboard [path] --task <task-id> --write [--out-dir <path>]"); | ||
| console.log(" or: topogram onboard [path] --bug <bug-id> --write [--out-dir <path>]"); | ||
| console.log(" or: topogram onboard [path] --generate [--run-verify]"); | ||
| console.log(""); | ||
| console.log("Plans the init/check/audit-bundle/generate/verify adoption loop. Defaults are read-only."); | ||
| } |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { buildAgentBrief } from "../../../../agent-brief.js"; | ||
| import { stablePublicStringify } from "../../../../public-paths.js"; | ||
| import { APPROX_CHARS_PER_TOKEN, textTokenStats } from "../../../../token-estimate.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const TRANSCRIPT_TEXT_KEYS = new Set(["content", "output", "stdout", "stderr", "text", "message"]); | ||
| const SELECTOR_LABELS = { | ||
| capabilityId: "capability", | ||
| workflowId: "workflow", | ||
| projectionId: "surface", | ||
| screenId: "screen", | ||
| layoutId: "layout", | ||
| regionId: "region", | ||
| designRealizationSetId: "component_map", | ||
| componentId: "widget", | ||
| entityId: "entity", | ||
| journeyId: "journey", | ||
| surfaceId: "surface", | ||
| domainId: "domain", | ||
| featureId: "feature", | ||
| pitchId: "pitch", | ||
| requirementId: "requirement", | ||
| acceptanceId: "acceptance", | ||
| taskId: "task", | ||
| planId: "plan", | ||
| bugId: "bug", | ||
| documentId: "document", | ||
| modeId: "mode" | ||
| }; | ||
| /** | ||
| * @param {AnyRecord} selectors | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function selectorSummary(selectors) { | ||
| /** @type {AnyRecord} */ | ||
| const summary = {}; | ||
| for (const [key, label] of Object.entries(SELECTOR_LABELS)) { | ||
| const value = selectors[key]; | ||
| if (!value) continue; | ||
| if (label === "surface" && summary.surface) continue; | ||
| summary[label] = value; | ||
| } | ||
| return summary; | ||
| } | ||
| /** | ||
| * @param {string} root | ||
| * @returns {string[]} | ||
| */ | ||
| function listTopogramSourceFiles(root) { | ||
| if (!fs.existsSync(root)) return []; | ||
| /** @type {string[]} */ | ||
| const files = []; | ||
| /** @param {string} directory */ | ||
| function walk(directory) { | ||
| for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { | ||
| const absolute = path.join(directory, entry.name); | ||
| if (entry.isDirectory()) { | ||
| walk(absolute); | ||
| } else if (entry.isFile() && entry.name.endsWith(".tg")) { | ||
| files.push(absolute); | ||
| } | ||
| } | ||
| } | ||
| walk(root); | ||
| return files.sort((left, right) => left.localeCompare(right)); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @param {string[]} out | ||
| */ | ||
| function collectAllStrings(value, out) { | ||
| if (typeof value === "string") { | ||
| out.push(value); | ||
| return; | ||
| } | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) collectAllStrings(item, out); | ||
| return; | ||
| } | ||
| if (value && typeof value === "object") { | ||
| for (const item of Object.values(value)) collectAllStrings(item, out); | ||
| } | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @param {string[]} out | ||
| */ | ||
| function collectTranscriptText(value, out) { | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) collectTranscriptText(item, out); | ||
| return; | ||
| } | ||
| if (!value || typeof value !== "object") return; | ||
| for (const [key, item] of Object.entries(value)) { | ||
| if (TRANSCRIPT_TEXT_KEYS.has(key)) { | ||
| collectAllStrings(item, out); | ||
| } else if (item && typeof item === "object") { | ||
| collectTranscriptText(item, out); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @param {string} transcriptPath | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function readTranscriptStats(transcriptPath) { | ||
| const raw = fs.readFileSync(transcriptPath, "utf8"); | ||
| const lines = raw.split(/\r?\n/).filter(/** @param {string} line */ (line) => line.trim().length > 0); | ||
| /** @type {string[]} */ | ||
| const text = []; | ||
| let malformedLines = 0; | ||
| for (const line of lines) { | ||
| try { | ||
| collectTranscriptText(JSON.parse(line), text); | ||
| } catch { | ||
| malformedLines += 1; | ||
| } | ||
| } | ||
| const combined = text.join("\n"); | ||
| return { | ||
| path: transcriptPath, | ||
| records: lines.length - malformedLines, | ||
| malformed_lines: malformedLines, | ||
| extracted_text_fields: text.length, | ||
| ...textTokenStats(combined) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {number} numerator | ||
| * @param {number} denominator | ||
| * @returns {number} | ||
| */ | ||
| function percent(numerator, denominator) { | ||
| if (!denominator) return 0; | ||
| return Math.round((numerator / denominator) * 1000) / 10; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildContextSavingsReport(options) { | ||
| const { | ||
| ast, | ||
| inputPath, | ||
| topogramRoot, | ||
| selectors, | ||
| sliceArtifact, | ||
| transcriptPath = null | ||
| } = options; | ||
| const publicContext = { | ||
| projectRoot: process.cwd(), | ||
| topogramRoot, | ||
| workspaceRoot: topogramRoot, | ||
| cwd: process.cwd() | ||
| }; | ||
| const sliceText = stablePublicStringify(sliceArtifact, publicContext); | ||
| const sliceStats = textTokenStats(sliceText); | ||
| const agentBrief = buildAgentBrief(inputPath, ast); | ||
| if (!agentBrief.ok) { | ||
| return { | ||
| ok: false, | ||
| error: "agent brief failed validation; cannot build context savings baseline", | ||
| validation: agentBrief.validation || null, | ||
| configPath: agentBrief.configPath || null | ||
| }; | ||
| } | ||
| const agentBriefText = stablePublicStringify(agentBrief.payload, publicContext); | ||
| const topogramFiles = listTopogramSourceFiles(topogramRoot); | ||
| const topogramSourceText = topogramFiles.map((file) => { | ||
| const relative = path.relative(topogramRoot, file).split(path.sep).join("/"); | ||
| return `## ${relative}\n${fs.readFileSync(file, "utf8")}`; | ||
| }).join("\n\n"); | ||
| const agentBriefStats = textTokenStats(agentBriefText); | ||
| const topogramStats = textTokenStats(topogramSourceText); | ||
| const baselineText = `# Agent brief\n${agentBriefText}\n\n# Topogram source\n${topogramSourceText}`; | ||
| const baselineStats = textTokenStats(baselineText); | ||
| const savingsTokens = baselineStats.estimated_tokens - sliceStats.estimated_tokens; | ||
| /** @type {string[]} */ | ||
| const caveats = [ | ||
| "Token counts are deterministic estimates using roughly four characters per token, not model-specific tokenizer results.", | ||
| "The baseline proxy is an upper-bound broad-discovery estimate based on agent brief plus all topo/**/*.tg source; real self-discovery depends on the task, tools, and agent behavior." | ||
| ]; | ||
| /** @type {AnyRecord|null} */ | ||
| let transcript = null; | ||
| if (transcriptPath) { | ||
| transcript = readTranscriptStats(transcriptPath); | ||
| const netTokens = transcript.estimated_tokens - sliceStats.estimated_tokens; | ||
| transcript.net_savings = { | ||
| estimated_tokens: netTokens, | ||
| percent: percent(netTokens, transcript.estimated_tokens) | ||
| }; | ||
| if (transcript.malformed_lines > 0) { | ||
| caveats.push(`${transcript.malformed_lines} transcript line(s) were malformed JSON and ignored.`); | ||
| } | ||
| } else { | ||
| caveats.push("Use --transcript <jsonl> to compare this slice with observed text from an actual agent run."); | ||
| } | ||
| return { | ||
| ok: true, | ||
| report: { | ||
| type: "context_savings_query", | ||
| version: 1, | ||
| selector: selectorSummary(selectors), | ||
| detail_level: sliceArtifact.detail_level || "standard", | ||
| tokenizer: { | ||
| kind: "approximate", | ||
| chars_per_token: APPROX_CHARS_PER_TOKEN | ||
| }, | ||
| slice: { | ||
| format: "json", | ||
| ...sliceStats | ||
| }, | ||
| baseline_proxy: { | ||
| name: "agent_brief_plus_full_topogram", | ||
| files_count: topogramFiles.length, | ||
| bytes: baselineStats.bytes, | ||
| estimated_tokens: baselineStats.estimated_tokens, | ||
| components: { | ||
| agent_brief: agentBriefStats, | ||
| topo_source: { | ||
| files_count: topogramFiles.length, | ||
| ...topogramStats | ||
| } | ||
| } | ||
| }, | ||
| upper_bound_savings: { | ||
| estimated_tokens: savingsTokens, | ||
| percent: percent(savingsTokens, baselineStats.estimated_tokens) | ||
| }, | ||
| transcript, | ||
| caveats | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} report | ||
| * @returns {string} | ||
| */ | ||
| export function formatContextSavingsMarkdown(report) { | ||
| const lines = []; | ||
| lines.push("# Context Savings"); | ||
| lines.push(""); | ||
| lines.push(`Detail level: ${report.detail_level || "standard"}`); | ||
| const selectorEntries = Object.entries(report.selector || {}); | ||
| lines.push(`Selector: ${selectorEntries.length > 0 ? selectorEntries.map(([key, value]) => `${key}=${value}`).join(", ") : "none"}`); | ||
| lines.push(""); | ||
| lines.push("| Measure | Estimated tokens | Bytes |"); | ||
| lines.push("| --- | ---: | ---: |"); | ||
| lines.push(`| Context slice | ${report.slice?.estimated_tokens ?? 0} | ${report.slice?.bytes ?? 0} |`); | ||
| lines.push(`| Broad discovery proxy | ${report.baseline_proxy?.estimated_tokens ?? 0} | ${report.baseline_proxy?.bytes ?? 0} |`); | ||
| lines.push(""); | ||
| lines.push(`Upper-bound savings: ${report.upper_bound_savings?.estimated_tokens ?? 0} estimated tokens (${report.upper_bound_savings?.percent ?? 0}%).`); | ||
| if (report.transcript) { | ||
| lines.push(`Transcript observed savings: ${report.transcript.net_savings?.estimated_tokens ?? 0} estimated tokens (${report.transcript.net_savings?.percent ?? 0}%).`); | ||
| lines.push(`Transcript records: ${report.transcript.records ?? 0}; malformed lines ignored: ${report.transcript.malformed_lines ?? 0}.`); | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Methodology"); | ||
| for (const caveat of report.caveats || []) { | ||
| lines.push(`- ${caveat}`); | ||
| } | ||
| lines.push(""); | ||
| return `${lines.join("\n")}`; | ||
| } |
| // @ts-check | ||
| import crypto from "node:crypto"; | ||
| import { textTokenStats } from "../../../../token-estimate.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord} value | ||
| * @returns {number} | ||
| */ | ||
| function estimatedTokens(value) { | ||
| return textTokenStats(JSON.stringify(value || {})).estimated_tokens; | ||
| } | ||
| /** | ||
| * @param {string} text | ||
| * @returns {string} | ||
| */ | ||
| function sha256(text) { | ||
| return crypto.createHash("sha256").update(String(text || ""), "utf8").digest("hex"); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {value is Record<string, unknown>} | ||
| */ | ||
| function isPlainObject(value) { | ||
| return Boolean(value) && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @param {number} limit | ||
| * @returns {string[]} | ||
| */ | ||
| function stringArray(value, limit = 30) { | ||
| return Array.isArray(value) ? value.slice(0, limit).map(String).filter(Boolean) : []; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function uxEvidence(value) { | ||
| const source = isPlainObject(value) ? value : {}; | ||
| return { | ||
| visible_actions: stringArray(source.visibleActions, 20), | ||
| state_copy: stringArray(source.stateCopy, 20), | ||
| role_affordances: stringArray(source.roleAffordances, 20) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {boolean} | ||
| */ | ||
| function hasPrimitiveValues(value) { | ||
| return isPlainObject(value) && Object.values(value).some((entry) => | ||
| entry === null || ["string", "number", "boolean"].includes(typeof entry) | ||
| ); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {unknown} | ||
| */ | ||
| function compactSeedValue(value) { | ||
| if (Array.isArray(value)) return value.slice(0, 2).map(compactSeedValue); | ||
| if (!isPlainObject(value)) return value; | ||
| return Object.fromEntries(Object.entries(value).slice(0, 12).map(([key, entry]) => [key, compactSeedValue(entry)])); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @param {string[]} prefix | ||
| * @param {AnyRecord[]} output | ||
| */ | ||
| function collectSeedSamples(value, prefix, output) { | ||
| if (output.length >= 80) return; | ||
| if (Array.isArray(value)) { | ||
| output.push({ | ||
| path: prefix.join("."), | ||
| key: prefix.at(-1) || null, | ||
| record_count: value.length, | ||
| sample_records: value.slice(0, 2).map(compactSeedValue) | ||
| }); | ||
| return; | ||
| } | ||
| if (!isPlainObject(value)) return; | ||
| if (prefix.length >= 2 && hasPrimitiveValues(value)) { | ||
| output.push({ | ||
| path: prefix.join("."), | ||
| key: prefix.at(-1) || null, | ||
| record_count: 1, | ||
| sample_records: [compactSeedValue(value)] | ||
| }); | ||
| } | ||
| for (const [key, entry] of Object.entries(value)) { | ||
| collectSeedSamples(entry, [...prefix, key], output); | ||
| if (output.length >= 80) return; | ||
| } | ||
| } | ||
| /** | ||
| * @param {AnyRecord} file | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function summarizeIncludedFile(file) { | ||
| if (!file?.ok || typeof file.content !== "string") { | ||
| return { | ||
| path: file?.path || null, | ||
| ok: Boolean(file?.ok), | ||
| bytes: file?.bytes || null, | ||
| estimated_tokens: file?.estimated_tokens || null, | ||
| error: file?.error || null | ||
| }; | ||
| } | ||
| const lines = file.content.split(/\r?\n/); | ||
| const relevantLines = []; | ||
| for (let index = 0; index < lines.length; index += 1) { | ||
| const text = lines[index]; | ||
| if (!/(topogram:|TODO|not_implemented|server\.listen|createServer|function |const server|url\.pathname|req\.method)/.test(text)) continue; | ||
| relevantLines.push({ | ||
| line: index + 1, | ||
| text: text.length > 180 ? `${text.slice(0, 177)}...` : text | ||
| }); | ||
| if (relevantLines.length >= 20) break; | ||
| } | ||
| const routeLines = []; | ||
| const routeBlocks = []; | ||
| const fallbackLines = []; | ||
| /** | ||
| * @param {number} startIndex | ||
| * @returns {{ text: string, line_count: number }} | ||
| */ | ||
| const routeBlockFrom = (startIndex) => { | ||
| const first = lines[startIndex] || ""; | ||
| if (!first.includes("{")) { | ||
| return { | ||
| text: first.length > 4000 ? `${first.slice(0, 3997)}...` : first, | ||
| line_count: 1 | ||
| }; | ||
| } | ||
| let balance = 0; | ||
| const blockLines = []; | ||
| for (let index = startIndex; index < lines.length && blockLines.length < 80; index += 1) { | ||
| const text = lines[index] || ""; | ||
| blockLines.push(text); | ||
| balance += (text.match(/\{/g) || []).length; | ||
| balance -= (text.match(/\}/g) || []).length; | ||
| if (balance <= 0 && index > startIndex) break; | ||
| } | ||
| const text = blockLines.join("\n"); | ||
| return { | ||
| text: text.length > 4000 ? `${text.slice(0, 3997)}...` : text, | ||
| line_count: blockLines.length | ||
| }; | ||
| }; | ||
| for (let index = 0; index < lines.length; index += 1) { | ||
| const text = lines[index]; | ||
| const routeMatch = text.match(/req\.method\s*===\s*["']([A-Z]+)["'][\s\S]*?url\.pathname\s*===\s*["']([^"']+)["']/); | ||
| if (routeMatch) { | ||
| const block = routeBlockFrom(index); | ||
| routeLines.push({ | ||
| line: index + 1, | ||
| method: routeMatch[1], | ||
| path: routeMatch[2], | ||
| text | ||
| }); | ||
| routeBlocks.push({ | ||
| line: index + 1, | ||
| method: routeMatch[1], | ||
| path: routeMatch[2], | ||
| text: block.text, | ||
| line_count: block.line_count | ||
| }); | ||
| } | ||
| if (/\bnotFound\(res\)\s*;?/.test(text) || /\b(?:return\s+)?sendJson\(res,\s*404\b/.test(text)) { | ||
| fallbackLines.push({ | ||
| line: index + 1, | ||
| text | ||
| }); | ||
| } | ||
| if (routeLines.length >= 80 && fallbackLines.length >= 4) break; | ||
| } | ||
| const markerBlocks = []; | ||
| const markerPattern = /(^[ \t]*\/\/ (topogram:(?:endpoint|custom) [^\n]+) start\n[\s\S]*?^[ \t]*\/\/ \2 end)/gm; | ||
| let markerMatch = null; | ||
| while ((markerMatch = markerPattern.exec(file.content)) && markerBlocks.length < 40) { | ||
| const before = file.content.slice(0, markerMatch.index); | ||
| markerBlocks.push({ | ||
| marker: markerMatch[2], | ||
| start_line: before.split(/\r?\n/).length, | ||
| line_count: markerMatch[1].split(/\r?\n/).length, | ||
| text: markerMatch[1].length > 4000 ? `${markerMatch[1].slice(0, 3997)}...` : markerMatch[1] | ||
| }); | ||
| } | ||
| let package_scripts = null; | ||
| let seed_keys = null; | ||
| /** @type {AnyRecord[]|null} */ | ||
| let seed_samples = null; | ||
| /** @type {AnyRecord[]|null} */ | ||
| let experience_checks = null; | ||
| if (file.path === "package.json") { | ||
| try { | ||
| const parsed = JSON.parse(file.content); | ||
| package_scripts = parsed && typeof parsed.scripts === "object" && parsed.scripts | ||
| ? Object.fromEntries(Object.entries(parsed.scripts).map(([key, value]) => [key, String(value)])) | ||
| : {}; | ||
| } catch { | ||
| package_scripts = null; | ||
| } | ||
| } | ||
| if (/seed-fixture\.json$/.test(String(file.path || ""))) { | ||
| try { | ||
| const parsed = JSON.parse(file.content); | ||
| seed_keys = Object.fromEntries(Object.entries(parsed || {}).map(([key, value]) => [ | ||
| key, | ||
| value && typeof value === "object" && !Array.isArray(value) | ||
| ? Object.keys(value).slice(0, 40) | ||
| : [] | ||
| ])); | ||
| seed_samples = []; | ||
| collectSeedSamples(parsed, [], seed_samples); | ||
| } catch { | ||
| seed_keys = null; | ||
| seed_samples = null; | ||
| } | ||
| } | ||
| if (/product-ui-contract\.json$/.test(String(file.path || ""))) { | ||
| try { | ||
| const parsed = JSON.parse(file.content); | ||
| experience_checks = recordArray(parsed?.checks).slice(0, 20).map((check) => ({ | ||
| id: check.id || null, | ||
| source: "product_ui_contract", | ||
| wave: check.wave || null, | ||
| path: check.path || "/", | ||
| expect_status: check.expectStatus || 200, | ||
| expect_content_type: check.expectContentType || null, | ||
| required_text: stringArray(check.requiredText, 30), | ||
| forbidden_text: stringArray(check.forbiddenText, 30), | ||
| required_tags: stringArray(check.requiredTags, 12), | ||
| min_sections: Number(check.minSections || 0), | ||
| ux_evidence: uxEvidence(check.uxEvidence) | ||
| })); | ||
| } catch { | ||
| experience_checks = null; | ||
| } | ||
| } | ||
| return { | ||
| path: file.path, | ||
| ok: true, | ||
| bytes: file.bytes, | ||
| estimated_tokens: file.estimated_tokens, | ||
| sha256: sha256(file.content), | ||
| line_count: lines.length, | ||
| todo_count: lines.filter((line) => /TODO|not_implemented/.test(line)).length, | ||
| markers: lines | ||
| .map((line) => line.match(/topogram:(?:endpoint|custom) [^\n]+/)?.[0] || null) | ||
| .filter(Boolean) | ||
| .slice(0, 40), | ||
| relevant_lines: relevantLines, | ||
| route_lines: routeLines, | ||
| route_blocks: routeBlocks, | ||
| fallback_lines: fallbackLines, | ||
| marker_blocks: markerBlocks, | ||
| package_scripts, | ||
| seed_keys, | ||
| seed_samples, | ||
| experience_checks | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} files | ||
| * @param {boolean} compact | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function fileContextForOutput(files, compact) { | ||
| return compact ? files.map(summarizeIncludedFile) : files; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} diagnostics | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function compactDiagnostics(diagnostics) { | ||
| return diagnostics.slice(0, 8).map((diagnostic) => ({ | ||
| id: diagnostic.id || null, | ||
| category: diagnostic.category || "unknown", | ||
| message: diagnostic.message || null, | ||
| file: diagnostic.file || null, | ||
| line: diagnostic.line || null, | ||
| statement: diagnostic.statement || null, | ||
| expected: diagnostic.expected || null, | ||
| suggested_action: diagnostic.suggested_action || null, | ||
| example: diagnostic.example || null, | ||
| excerpt: diagnostic.excerpt || null | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} checkPayload | ||
| * @param {AnyRecord|null|undefined} repairReport | ||
| * @returns {{ diagnostics: AnyRecord[], groups: AnyRecord[], errorCount: number, warningCount: number }} | ||
| */ | ||
| export function diagnosticSummary(checkPayload, repairReport) { | ||
| const diagnostics = Array.isArray(repairReport?.diagnostics) | ||
| ? compactDiagnostics(repairReport.diagnostics) | ||
| : (Array.isArray(checkPayload?.errors) ? checkPayload.errors.slice(0, 8) : []); | ||
| const groups = Array.isArray(repairReport?.groups) ? repairReport.groups : []; | ||
| return { | ||
| diagnostics, | ||
| groups, | ||
| errorCount: Number(repairReport?.source?.error_count ?? (Array.isArray(checkPayload?.errors) ? checkPayload.errors.length : 0)), | ||
| warningCount: Number(repairReport?.source?.warning_count ?? (Array.isArray(checkPayload?.warnings) ? checkPayload.warnings.length : 0)) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} contract | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function compactEndpointContract(contract) { | ||
| return { | ||
| id: contract.id || null, | ||
| capability_id: contract.capability_id || null, | ||
| method: contract.method || null, | ||
| path: contract.path || null, | ||
| success_status: contract.success_status || null, | ||
| auth: contract.auth || null, | ||
| request: contract.request || null, | ||
| response: contract.response || null, | ||
| seed_examples: Array.isArray(contract.seed_examples) | ||
| ? contract.seed_examples.slice(0, 2).map((seed) => ({ | ||
| id: seed.id || null, | ||
| purpose: seed.purpose || null, | ||
| record_count: Array.isArray(seed.records) ? seed.records.length : 0, | ||
| sample_records: (seed.records || []).slice(0, 2) | ||
| })) | ||
| : [], | ||
| verification_ids: contract.verification_ids || [] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {{ state: string, activeBucket: string, selector: AnyRecord, workflow: AnyRecord, buckets: AnyRecord[], implementationContracts: AnyRecord[], requiredOperations?: AnyRecord[], implementationPacket: AnyRecord|null, scaffold: AnyRecord, policy: AnyRecord, fileContext: AnyRecord[], topogramCheck: AnyRecord, nextAction: string, nextCommands: string[], compact: boolean }} input | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function buildAgentPayload(input) { | ||
| if (!input.compact) return null; | ||
| const active = input.buckets.find((entry) => entry.id === input.activeBucket) || null; | ||
| const endpointContracts = input.implementationContracts.map(compactEndpointContract); | ||
| const packageContext = input.fileContext.find((file) => file?.path === "package.json" && file?.ok); | ||
| return { | ||
| type: "implementation_prep_agent_payload", | ||
| version: 1, | ||
| state: input.state, | ||
| recommended_next_action: input.activeBucket, | ||
| active_bucket: input.activeBucket, | ||
| selector: input.selector, | ||
| workflow_step: input.workflow, | ||
| app_work_policy: input.policy, | ||
| topogram_check: input.topogramCheck, | ||
| active_bucket_packet: active, | ||
| bucket_summaries: input.buckets.map((entry) => ({ | ||
| id: entry.id, | ||
| status: entry.status, | ||
| summary: entry.summary, | ||
| commands: entry.id === input.activeBucket ? entry.commands : [], | ||
| next_queries: entry.next_queries || [], | ||
| estimated_tokens: entry.estimated_tokens | ||
| })), | ||
| endpoint_contracts: endpointContracts, | ||
| required_operations: recordArray(input.requiredOperations).map((operation) => ({ | ||
| id: operation.id || null, | ||
| wave: operation.wave || null, | ||
| method: operation.method || null, | ||
| path: operation.path || null, | ||
| success_status: operation.success_status || operation.success || null, | ||
| response: operation.response || { | ||
| container: operation.response_container || null | ||
| }, | ||
| response_container: operation.response_container || null | ||
| })), | ||
| seed_summaries: input.implementationContracts.flatMap((contract) => | ||
| (contract.seed_examples || []).map((/** @type {AnyRecord} */ seed) => ({ | ||
| endpoint_id: contract.id, | ||
| entity_id: contract.response?.entity_id || null, | ||
| seed_id: seed.id || null, | ||
| purpose: seed.purpose || null, | ||
| record_count: Array.isArray(seed.records) ? seed.records.length : 0, | ||
| sample_records: (seed.records || []).slice(0, 2) | ||
| })) | ||
| ), | ||
| scaffold_status: input.scaffold, | ||
| implementation_packet: input.implementationPacket, | ||
| file_context: input.fileContext, | ||
| project_commands: packageContext?.package_scripts || null, | ||
| omitted_sections: [ | ||
| "implementation_slice", | ||
| "inactive_bucket_payloads", | ||
| "full_included_file_content" | ||
| ], | ||
| next_queries: active?.next_queries || [], | ||
| next_action: input.nextAction, | ||
| next_commands: input.nextCommands, | ||
| estimated_tokens: estimatedTokens({ | ||
| state: input.state, | ||
| workflow_step: input.workflow, | ||
| active_bucket_packet: active, | ||
| endpoint_contracts: endpointContracts, | ||
| file_context: input.fileContext | ||
| }) | ||
| }; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord|null|undefined} operation | ||
| * @returns {string} | ||
| */ | ||
| export function operationKey(operation) { | ||
| return `${String(operation?.method || "").toUpperCase()} ${String(operation?.path || "")}`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} contract | ||
| * @param {AnyRecord|null|undefined} operation | ||
| * @returns {boolean} | ||
| */ | ||
| export function contractMatchesOperation(contract, operation) { | ||
| if (!contract || !operation) return false; | ||
| return operationKey(contract) === operationKey(operation); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} requiredOperations | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function requiredOperationContracts(requiredOperations) { | ||
| return (requiredOperations || []).map((operation) => ({ | ||
| id: operation.id || null, | ||
| wave: operation.wave || null, | ||
| method: operation.method || null, | ||
| path: operation.path || null, | ||
| success: operation.success || null, | ||
| response_container: operation.response_container || null | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @param {AnyRecord[]} requiredOperations | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function missingRequiredOperationContracts(implementationContracts, requiredOperations) { | ||
| return requiredOperationContracts(requiredOperations) | ||
| .filter((operation) => !(implementationContracts || []).some((contract) => contractMatchesOperation(contract, operation))) | ||
| .sort((a, b) => operationKey(a).localeCompare(operationKey(b))); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @param {AnyRecord[]} requiredOperations | ||
| * @returns {string[]} | ||
| */ | ||
| export function coveredRequiredOperationIds(implementationContracts, requiredOperations) { | ||
| return requiredOperationContracts(requiredOperations) | ||
| .filter((operation) => (implementationContracts || []).some((contract) => contractMatchesOperation(contract, operation))) | ||
| .map((operation) => operation.id) | ||
| .filter(Boolean) | ||
| .sort(); | ||
| } |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const CONTRACT_TERM_STOPWORDS = new Set("a an and api app application behavior current for from get implementation implement local post put patch delete route routes the to with".split(" ")); | ||
| /** | ||
| * @param {string} value | ||
| * @returns {Set<string>} | ||
| */ | ||
| function contractTerms(value) { | ||
| const terms = new Set(); | ||
| const normalized = String(value || "") | ||
| .replace(/([a-z])([A-Z])/g, "$1 $2") | ||
| .replace(/[_:/.-]+/g, " ") | ||
| .toLowerCase(); | ||
| for (const token of normalized.split(/[^a-z0-9]+/).filter(Boolean)) { | ||
| if (CONTRACT_TERM_STOPWORDS.has(token)) continue; | ||
| if (token.length < 3 && token !== "no") continue; | ||
| terms.add(token.endsWith("s") && token.length > 4 ? token.slice(0, -1) : token); | ||
| } | ||
| return terms; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {string|null|undefined} id | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function graphRecordById(graph, id) { | ||
| if (!graph || !id) return null; | ||
| if (graph.byId && typeof graph.byId.get === "function") return graph.byId.get(String(id)) || null; | ||
| return (graph.statements || []).find((/** @type {AnyRecord} */ statement) => statement?.id === id) || null; | ||
| } | ||
| /** | ||
| * @param {any} record | ||
| * @returns {string|null} | ||
| */ | ||
| function refId(record) { | ||
| if (!record) return null; | ||
| return typeof record === "string" ? record : (record.id ? String(record.id) : null); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @returns {string} | ||
| */ | ||
| function taskContractText(graph, focus) { | ||
| const task = focus?.id ? graphRecordById(graph, String(focus.id)) || focus : focus; | ||
| const featureId = refId(task?.feature); | ||
| const feature = featureId ? graphRecordById(graph, featureId) : null; | ||
| return [ | ||
| task?.id, | ||
| task?.name, | ||
| task?.description, | ||
| task?.intent, | ||
| task?.success, | ||
| ...(task?.entrypoints || []), | ||
| feature?.id, | ||
| feature?.name, | ||
| feature?.description, | ||
| feature?.intent | ||
| ].filter(Boolean).join(" "); | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} value | ||
| * @returns {string|null} | ||
| */ | ||
| function normalizedWave(value) { | ||
| const raw = String(value || "").toLowerCase().trim(); | ||
| if (!raw) return null; | ||
| const match = raw.match(/\bwave[\s_-]*([0-9]+)\b/); | ||
| if (match) return `wave_${match[1]}`; | ||
| return raw.replace(/[\s-]+/g, "_"); | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} projectRoot | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function readPublicApiOperations(projectRoot) { | ||
| if (!projectRoot) return []; | ||
| const contractPath = path.join(String(projectRoot), "api-contract.json"); | ||
| if (!fs.existsSync(contractPath)) return []; | ||
| try { | ||
| const parsed = JSON.parse(fs.readFileSync(contractPath, "utf8")); | ||
| if (!Array.isArray(parsed?.endpoints)) return []; | ||
| return parsed.endpoints | ||
| .filter((/** @type {AnyRecord} */ endpoint) => | ||
| endpoint | ||
| && endpoint.method | ||
| && endpoint.path | ||
| && String(endpoint.path).startsWith("/api/") | ||
| ) | ||
| .map((/** @type {AnyRecord} */ endpoint) => ({ | ||
| id: endpoint.id ? String(endpoint.id) : null, | ||
| wave: endpoint.wave ? String(endpoint.wave) : null, | ||
| method: String(endpoint.method).toUpperCase(), | ||
| path: String(endpoint.path), | ||
| success: endpoint.success || null, | ||
| response_container: endpoint.response_container || null | ||
| })); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @param {string|null|undefined} projectRoot | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function requiredApiOperationsForTask(graph, focus, projectRoot) { | ||
| const operations = readPublicApiOperations(projectRoot); | ||
| if (operations.length === 0 || focus?.kind !== "task") return []; | ||
| const text = taskContractText(graph, focus); | ||
| const normalizedText = text.toLowerCase().replace(/[\s-]+/g, "_"); | ||
| const taskTerms = contractTerms(text); | ||
| const waveMatches = operations.filter((operation) => { | ||
| const wave = normalizedWave(operation.wave); | ||
| return wave && normalizedText.includes(wave); | ||
| }); | ||
| if (waveMatches.length > 0) return waveMatches; | ||
| return operations.filter((operation) => { | ||
| const opTerms = contractTerms([operation.id, operation.method, operation.path].filter(Boolean).join(" ")); | ||
| let score = 0; | ||
| for (const term of opTerms) { | ||
| if (taskTerms.has(term)) score += 1; | ||
| } | ||
| return score >= 2; | ||
| }); | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {{ | ||
| * state: string, | ||
| * activeBucket: string, | ||
| * selectorFragment: string, | ||
| * scaffold: AnyRecord, | ||
| * candidateGuidance?: AnyRecord|null | ||
| * }} input | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildImplementationWorkflowStep(input) { | ||
| const prepCommand = `topogram query implementation-prep ./topo${input.selectorFragment} --detail compact --json`; | ||
| const commandWithFiles = `topogram query implementation-prep ./topo${input.selectorFragment} --detail compact --include-file server.mjs --include-file seed-fixture.json --include-file package.json --json`; | ||
| const base = { | ||
| state: input.state, | ||
| active_bucket: input.activeBucket, | ||
| rerun_command: commandWithFiles | ||
| }; | ||
| if (input.state === "model_invalid") { | ||
| return { | ||
| ...base, | ||
| instruction: "Repair the Topogram model using the diagnostics packet before editing app code.", | ||
| success_condition: "topogram check passes and implementation-prep no longer reports model_invalid.", | ||
| allowed_actions: ["run_topogram:repair_model", "edit:topo/**", "run_topogram:check", "run_topogram:implementation_prep"], | ||
| blocked_actions: ["edit:app_code", "run_public_check", "run_topogram:scaffold"], | ||
| exact_next_command: "topogram query repair-model ./topo --json" | ||
| }; | ||
| } | ||
| if (input.state === "task_unlinked") { | ||
| return { | ||
| ...base, | ||
| instruction: "Link the current task to the candidate model capabilities and verification records before implementation.", | ||
| success_condition: "The task packet includes endpoint implementation contracts for the current feature.", | ||
| allowed_actions: ["read:topo/**", "edit:current_task_affects", "edit:current_task_verification_refs", "run_topogram:implementation_prep"], | ||
| blocked_actions: ["edit:app_code", "run_public_check", "run_topogram:scaffold"], | ||
| exact_next_command: prepCommand, | ||
| task_link_guidance: input.candidateGuidance || null | ||
| }; | ||
| } | ||
| if (input.state === "modeling_needed") { | ||
| return { | ||
| ...base, | ||
| instruction: "Model only the current feature scope, validate it, then rerun implementation-prep.", | ||
| success_condition: "The current task has endpoint contracts and implementation-prep advances beyond model_feature.", | ||
| allowed_actions: ["run_topogram:modeling_guide", "edit:topo/**", "run_topogram:check", "run_topogram:implementation_prep"], | ||
| blocked_actions: ["edit:app_code", "run_public_check", "run_topogram:scaffold"], | ||
| exact_next_command: "topogram query modeling-guide ./topo --mode greenfield-app --format markdown" | ||
| }; | ||
| } | ||
| if (input.state === "scaffold_needed") { | ||
| return { | ||
| ...base, | ||
| instruction: "Refresh the generated node HTTP scaffold before app implementation.", | ||
| success_condition: "Scaffold manifest and server markers are current for the endpoint contracts.", | ||
| allowed_actions: ["run_topogram:scaffold", "run_topogram:implementation_prep"], | ||
| blocked_actions: ["edit:app_code", "run_public_check"], | ||
| exact_next_command: input.scaffold.command || "topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --write --out-dir ." | ||
| }; | ||
| } | ||
| if (input.state === "verification_needed") { | ||
| return { | ||
| ...base, | ||
| instruction: "Run the proof commands named by the packet and close remaining verification gaps.", | ||
| success_condition: "The named proof commands pass for the current task.", | ||
| allowed_actions: ["run_public_check", "run_project_verify", "run_topogram:sdlc-proof-gaps"], | ||
| blocked_actions: [], | ||
| exact_next_command: "npm run verify" | ||
| }; | ||
| } | ||
| return { | ||
| ...base, | ||
| instruction: "Implement the current task from code_edit_targets. Apply each target's patch_ready replace_file_text args when present, then run_public_check. Treat included file summaries as already-read context; do not read server.mjs, seed-fixture.json, or package.json unless a patch_ready anchor is missing or a proof fails.", | ||
| success_condition: "The app behavior for the current task passes run_public_check and the final hidden checks.", | ||
| allowed_actions: ["edit:app_code", "run_public_check", "run_topogram:implementation_prep"], | ||
| blocked_actions: [], | ||
| exact_next_command: "edit server.mjs" | ||
| }; | ||
| } |
| // @ts-check | ||
| import { | ||
| contractMatchesOperation, | ||
| coveredRequiredOperationIds, | ||
| missingRequiredOperationContracts, | ||
| requiredOperationContracts | ||
| } from "./implementation-prep-operation-coverage.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {any} value | ||
| * @returns {string|null} | ||
| */ | ||
| function refId(value) { | ||
| if (!value) return null; | ||
| if (typeof value === "string") return value; | ||
| return value.id ? String(value.id) : null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @returns {Map<string, AnyRecord>} | ||
| */ | ||
| function graphIdMap(graph) { | ||
| /** @type {Map<string, AnyRecord>} */ | ||
| const map = new Map(); | ||
| if (!graph) return map; | ||
| if (graph.byId && typeof graph.byId.forEach === "function") { | ||
| graph.byId.forEach((/** @type {AnyRecord} */ value, /** @type {string} */ key) => map.set(String(key), value)); | ||
| } | ||
| for (const statement of graph.statements || []) { | ||
| if (statement?.id && !map.has(statement.id)) map.set(statement.id, statement); | ||
| } | ||
| return map; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {string} kind | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function graphRecords(graph, kind) { | ||
| if (!graph) return []; | ||
| if (Array.isArray(graph.byKind?.[kind])) return graph.byKind[kind]; | ||
| return (graph.statements || []).filter((/** @type {AnyRecord} */ statement) => statement?.kind === kind); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} record | ||
| * @returns {boolean} | ||
| */ | ||
| function isActiveRecord(record) { | ||
| const status = String(record?.status || "active"); | ||
| return !["archived", "rejected", "deprecated"].includes(status); | ||
| } | ||
| const TERM_STOPWORDS = new Set("a an and as at task clinic ops operation operations implementation implement implemented implementing feature current wave base model app application behavior behaviour topogram context prototype required local use using used with without while when then for from into through handling handle status".split(" ")); | ||
| const NON_BLOCKING_MODIFIER_TERMS = new Set(["aware", "show"]); | ||
| /** | ||
| * @param {Array<string|null|undefined>} values | ||
| * @returns {Set<string>} | ||
| */ | ||
| function textTerms(values) { | ||
| const terms = new Set(); | ||
| for (const value of values) { | ||
| const normalized = String(value || "") | ||
| .replace(/([a-z])([A-Z])/g, "$1 $2") | ||
| .replace(/[_:/.-]+/g, " ") | ||
| .toLowerCase(); | ||
| for (const token of normalized.split(/[^a-z0-9]+/).filter(Boolean)) { | ||
| if (TERM_STOPWORDS.has(token)) continue; | ||
| if (token.length < 3 && token !== "no") continue; | ||
| let baseTerm = token.endsWith("s") && token.length > 4 ? token.slice(0, -1) : token; | ||
| if (baseTerm === "awareness") baseTerm = "aware"; | ||
| if (baseTerm.endsWith("ing") && baseTerm.length > 6) baseTerm = baseTerm.slice(0, -3); | ||
| terms.add(baseTerm); | ||
| } | ||
| } | ||
| return terms; | ||
| } | ||
| /** | ||
| * @param {Set<string>} terms | ||
| * @returns {{ blocking: Set<string>, ignored: Set<string> }} | ||
| */ | ||
| function splitBlockingTerms(terms) { | ||
| const blocking = new Set(); | ||
| const ignored = new Set(); | ||
| for (const term of terms) { | ||
| if (NON_BLOCKING_MODIFIER_TERMS.has(term)) { | ||
| ignored.add(term); | ||
| } else { | ||
| blocking.add(term); | ||
| } | ||
| } | ||
| return { blocking, ignored }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @returns {Set<string>} | ||
| */ | ||
| function taskFeatureTerms(graph, focus) { | ||
| if (!graph || focus?.kind !== "task" || !focus.id) return new Set(); | ||
| const byId = graphIdMap(graph); | ||
| const task = byId.get(String(focus.id)) || focus; | ||
| return textTerms([ | ||
| task.id, | ||
| task.name, | ||
| task.description, | ||
| task.intent, | ||
| task.success, | ||
| ...(task.entrypoints || []) | ||
| ]); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function featureForTask(graph, focus) { | ||
| if (!graph || focus?.kind !== "task" || !focus.id) return null; | ||
| const byId = graphIdMap(graph); | ||
| const task = byId.get(String(focus.id)) || focus; | ||
| const featureId = refId(task.feature); | ||
| return featureId ? byId.get(String(featureId)) || null : null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} feature | ||
| * @returns {string[]} | ||
| */ | ||
| function featureEndpointIds(feature) { | ||
| return (feature?.endpoints || []).map(refId).filter(Boolean).map(String).sort(); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} feature | ||
| * @returns {string[]} | ||
| */ | ||
| function featureCapabilityIds(feature) { | ||
| return (feature?.capabilities || []).map(refId).filter(Boolean).map(String).sort(); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @returns {Set<string>} | ||
| */ | ||
| function implementationContractTerms(graph, implementationContracts) { | ||
| const terms = new Set(); | ||
| for (const contract of implementationContracts || []) { | ||
| for (const term of implementationContractTermSet(graph, contract)) terms.add(term); | ||
| } | ||
| return terms; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} contract | ||
| * @returns {Set<string>} | ||
| */ | ||
| function implementationContractTermSet(graph, contract) { | ||
| if (!contract) return new Set(); | ||
| const byId = graphIdMap(graph); | ||
| const endpoint = byId.get(String(contract.id || "")) || null; | ||
| const capabilityId = refId(contract.capability) || refId(contract.capability_id) || refId(endpoint?.capability); | ||
| const capability = capabilityId ? byId.get(capabilityId) : null; | ||
| const responseEntityId = refId(contract.response?.entity_id) | ||
| || refId(contract.responseEntity) | ||
| || refId(endpoint?.responseEntity) | ||
| || refId(endpoint?.response_entity); | ||
| const responseEntity = responseEntityId ? byId.get(responseEntityId) : null; | ||
| return textTerms([ | ||
| contract.id, | ||
| contract.name, | ||
| contract.description, | ||
| contract.method, | ||
| contract.path, | ||
| contract.capability_id, | ||
| endpoint?.id, | ||
| endpoint?.name, | ||
| endpoint?.description, | ||
| endpoint?.method, | ||
| endpoint?.path, | ||
| capability?.id, | ||
| capability?.name, | ||
| capability?.description, | ||
| responseEntity?.id, | ||
| responseEntity?.name, | ||
| responseEntity?.description | ||
| ]); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} record | ||
| * @returns {Array<string|null|undefined>} | ||
| */ | ||
| function directRecordText(record) { | ||
| if (!record) return []; | ||
| return [ | ||
| record.id, | ||
| record.name, | ||
| record.title, | ||
| record.description, | ||
| record.intent, | ||
| record.goal, | ||
| record.kind, | ||
| record.path, | ||
| record.method, | ||
| record.response_result, | ||
| record.response_container | ||
| ]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} record | ||
| * @param {Map<string, AnyRecord>} byId | ||
| * @returns {Set<string>} | ||
| */ | ||
| function linkedRecordTerms(record, byId) { | ||
| const terms = textTerms(directRecordText(record)); | ||
| const relatedIds = [ | ||
| refId(record?.capability), | ||
| refId(record?.entity), | ||
| refId(record?.screen), | ||
| refId(record?.responseEntity), | ||
| refId(record?.response_entity), | ||
| refId(record?.response?.entity_id), | ||
| ...(record?.reads || []).map(refId), | ||
| ...(record?.creates || []).map(refId), | ||
| ...(record?.updates || []).map(refId), | ||
| ...(record?.deletes || []).map(refId), | ||
| ...(record?.actors || []).map(refId) | ||
| ].filter(Boolean); | ||
| for (const relation of record?.relations || []) { | ||
| relatedIds.push(refId(relation?.target) || refId(relation?.entity)); | ||
| } | ||
| for (const step of record?.steps || []) { | ||
| const stepTerms = textTerms([ | ||
| step?.id, | ||
| step?.name, | ||
| step?.intent, | ||
| step?.description, | ||
| step?.frequency, | ||
| refId(step?.screen), | ||
| refId(step?.capability) | ||
| ]); | ||
| for (const term of stepTerms) terms.add(term); | ||
| const stepCapability = refId(step?.capability); | ||
| if (stepCapability) relatedIds.push(stepCapability); | ||
| const stepScreen = refId(step?.screen); | ||
| if (stepScreen) relatedIds.push(stepScreen); | ||
| } | ||
| for (const id of relatedIds) { | ||
| const related = id ? byId.get(String(id)) : null; | ||
| if (!related || !isActiveRecord(related)) continue; | ||
| const relatedTerms = textTerms(directRecordText(related)); | ||
| for (const term of relatedTerms) terms.add(term); | ||
| } | ||
| return terms; | ||
| } | ||
| /** | ||
| * Features are authoritative scope records. Candidate matching should prefer | ||
| * the feature's own shape and operation names over broad aggregate capability | ||
| * descriptions that can mention future work. | ||
| * | ||
| * @param {AnyRecord|null|undefined} feature | ||
| * @param {Map<string, AnyRecord>} byId | ||
| * @returns {Set<string>} | ||
| */ | ||
| function featureCoreTerms(feature, byId) { | ||
| const terms = textTerms([ | ||
| feature?.id, | ||
| feature?.name, | ||
| feature?.description, | ||
| feature?.intent | ||
| ]); | ||
| for (const id of [ | ||
| ...featureEndpointIds(feature), | ||
| ...featureCapabilityIds(feature), | ||
| ...(feature?.entities || []).map(refId).filter(Boolean), | ||
| ...(feature?.seedData || []).map(refId).filter(Boolean) | ||
| ]) { | ||
| const record = byId.get(String(id)); | ||
| const recordTerms = textTerms([ | ||
| record?.id, | ||
| record?.name, | ||
| record?.path, | ||
| record?.method | ||
| ]); | ||
| for (const term of recordTerms) terms.add(term); | ||
| } | ||
| return terms; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @returns {Set<string>} | ||
| */ | ||
| function taskAffectsTerms(graph, focus) { | ||
| const terms = new Set(); | ||
| if (!graph || focus?.kind !== "task" || !focus.id) return terms; | ||
| const byId = graphIdMap(graph); | ||
| const task = byId.get(String(focus.id)) || focus; | ||
| const feature = featureForTask(graph, focus); | ||
| const affects = [ | ||
| ...(task.affects || []).map(refId).filter(Boolean), | ||
| ...featureEndpointIds(feature), | ||
| ...featureCapabilityIds(feature), | ||
| ...(feature?.entities || []).map(refId).filter(Boolean), | ||
| ...(feature?.seedData || []).map(refId).filter(Boolean) | ||
| ]; | ||
| for (const id of affects) { | ||
| const record = byId.get(String(id)); | ||
| if (!record || !isActiveRecord(record)) continue; | ||
| const recordTerms = linkedRecordTerms(record, byId); | ||
| for (const term of recordTerms) terms.add(term); | ||
| } | ||
| return terms; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function featureCoverageForTask(graph, focus, implementationContracts) { | ||
| return featureCoverageForTaskWithRequiredOperations(graph, focus, implementationContracts, []); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @param {AnyRecord[]} requiredOperations | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function featureCoverageForTaskWithRequiredOperations(graph, focus, implementationContracts, requiredOperations = []) { | ||
| const rawRequiredTerms = taskFeatureTerms(graph, focus); | ||
| const { blocking: requiredTerms, ignored: ignoredModifierTerms } = splitBlockingTerms(rawRequiredTerms); | ||
| const linkedFeature = featureForTask(graph, focus); | ||
| const linkedFeatureEndpointIds = new Set(featureEndpointIds(linkedFeature)); | ||
| const byId = graphIdMap(graph); | ||
| const hasRequiredOperations = requiredOperations.length > 0; | ||
| const requiredFeatureEndpointIds = new Set(); | ||
| const extraFeatureEndpointIds = []; | ||
| if (hasRequiredOperations) { | ||
| for (const id of linkedFeatureEndpointIds) { | ||
| const endpoint = byId.get(String(id)) || { id }; | ||
| if (requiredOperations.some((operation) => contractMatchesOperation(endpoint, operation))) { | ||
| requiredFeatureEndpointIds.add(String(id)); | ||
| } else { | ||
| extraFeatureEndpointIds.push(String(id)); | ||
| } | ||
| } | ||
| } | ||
| const contractEndpointIds = new Set((implementationContracts || []).map((contract) => String(contract.id || "")).filter(Boolean)); | ||
| const endpointContractTerms = implementationContractTerms(graph, implementationContracts); | ||
| const linkedTaskRecordTerms = taskAffectsTerms(graph, focus); | ||
| const coveredTerms = new Set([...endpointContractTerms, ...linkedTaskRecordTerms]); | ||
| const uncoveredTerms = [...requiredTerms].filter((term) => !coveredTerms.has(term)).sort(); | ||
| const missingTerms = hasRequiredOperations ? [] : uncoveredTerms; | ||
| const advisoryTerms = hasRequiredOperations ? uncoveredTerms : []; | ||
| const coveredRequiredTerms = [...requiredTerms].filter((term) => coveredTerms.has(term)).sort(); | ||
| const endpointCoveredTerms = [...requiredTerms].filter((term) => endpointContractTerms.has(term)).sort(); | ||
| const linkedRecordCoveredTerms = [...requiredTerms].filter((term) => linkedTaskRecordTerms.has(term)).sort(); | ||
| const blockingFeatureEndpointIds = hasRequiredOperations ? requiredFeatureEndpointIds : linkedFeatureEndpointIds; | ||
| const missingFeatureEndpointIds = [...blockingFeatureEndpointIds].filter((id) => !contractEndpointIds.has(id)).sort(); | ||
| const requiredOperationContractRecords = requiredOperationContracts(requiredOperations); | ||
| const missingRequiredOperations = missingRequiredOperationContracts(implementationContracts, requiredOperations); | ||
| const sufficient = missingTerms.length === 0 | ||
| && missingFeatureEndpointIds.length === 0 | ||
| && missingRequiredOperations.length === 0; | ||
| return { | ||
| coverage_mode: hasRequiredOperations ? "required_operations" : "feature_scope", | ||
| required_terms: [...requiredTerms].sort(), | ||
| ignored_modifier_terms: [...ignoredModifierTerms].sort(), | ||
| covered_terms: coveredRequiredTerms, | ||
| advisory_terms: advisoryTerms, | ||
| missing_terms: missingTerms, | ||
| feature_id: linkedFeature?.id || null, | ||
| feature_endpoint_ids: [...linkedFeatureEndpointIds].sort(), | ||
| missing_feature_endpoint_ids: missingFeatureEndpointIds, | ||
| extra_feature_endpoint_ids: extraFeatureEndpointIds.sort(), | ||
| required_operations: requiredOperationContractRecords, | ||
| missing_required_operations: missingRequiredOperations, | ||
| coverage_sources: { | ||
| endpoint_contract_terms: endpointCoveredTerms, | ||
| linked_task_record_terms: linkedRecordCoveredTerms, | ||
| required_operation_contract_ids: coveredRequiredOperationIds(implementationContracts, requiredOperations) | ||
| }, | ||
| sufficient, | ||
| reason: sufficient | ||
| ? (hasRequiredOperations | ||
| ? "Endpoint contracts cover the visible required API operations for the current task; unmatched descriptive terms or extra feature endpoints are advisory." | ||
| : "Linked endpoint contracts and current task affects cover the current task feature scope.") | ||
| : (hasRequiredOperations | ||
| ? "Endpoint contracts do not cover all visible required API operations for the current task." | ||
| : "Linked endpoint contracts and current task affects do not cover all feature terms or feature endpoint contracts for the current task.") | ||
| }; | ||
| } | ||
| /** | ||
| * @param {Set<string>} haystack | ||
| * @param {Set<string>} needles | ||
| * @returns {{ score: number, matches: string[] }} | ||
| */ | ||
| function termScore(haystack, needles) { | ||
| const matches = []; | ||
| for (const term of needles) { | ||
| if (haystack.has(term)) matches.push(term); | ||
| } | ||
| return { score: matches.length, matches }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} candidate | ||
| * @returns {string[]} | ||
| */ | ||
| function candidateSelectionTerms(candidate) { | ||
| const primaryTerms = Array.isArray(candidate.primary_matched_terms) | ||
| ? candidate.primary_matched_terms | ||
| : []; | ||
| if (primaryTerms.length > 0) return primaryTerms; | ||
| return Array.isArray(candidate.matched_terms) ? candidate.matched_terms : []; | ||
| } | ||
| /** | ||
| * @param {Set<string>} coveredTerms | ||
| * @param {Set<string>} requiredTerms | ||
| * @returns {string[]} | ||
| */ | ||
| function missingTerms(coveredTerms, requiredTerms) { | ||
| return [...requiredTerms].filter((term) => !coveredTerms.has(term)).sort(); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @param {AnyRecord[]} [requiredOperations] | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function currentFeatureImplementationContracts(graph, focus, implementationContracts, requiredOperations = []) { | ||
| if (!graph || focus?.kind !== "task" || !focus.id) return implementationContracts || []; | ||
| if (requiredOperations.length > 0) { | ||
| return (implementationContracts || []).filter((contract) => | ||
| requiredOperations.some((operation) => contractMatchesOperation(contract, operation)) | ||
| ); | ||
| } | ||
| const feature = featureForTask(graph, focus); | ||
| const endpointIds = new Set(featureEndpointIds(feature)); | ||
| if (endpointIds.size > 0) { | ||
| return (implementationContracts || []).filter((contract) => endpointIds.has(String(contract.id || ""))); | ||
| } | ||
| const capabilityIds = new Set(featureCapabilityIds(feature)); | ||
| if (capabilityIds.size > 0) { | ||
| return (implementationContracts || []).filter((contract) => capabilityIds.has(String(refId(contract.capability) || refId(contract.capability_id) || ""))); | ||
| } | ||
| const requiredTerms = splitBlockingTerms(taskFeatureTerms(graph, focus)).blocking; | ||
| if (requiredTerms.size === 0) return implementationContracts || []; | ||
| const byId = graphIdMap(graph); | ||
| const task = byId.get(String(focus.id)) || focus; | ||
| const taskVerificationIds = new Set((task.verificationRefs || task.verification_refs || []).map(refId).filter(Boolean)); | ||
| const currentVerificationCapabilityIds = new Set(); | ||
| for (const verificationId of taskVerificationIds) { | ||
| const verification = byId.get(String(verificationId)); | ||
| if (!verification || !isActiveRecord(verification)) continue; | ||
| const verificationTerms = linkedRecordTerms(verification, byId); | ||
| if (termScore(verificationTerms, requiredTerms).score < 2) continue; | ||
| for (const id of (verification.validates || []).map(refId).filter(Boolean)) { | ||
| currentVerificationCapabilityIds.add(String(id)); | ||
| } | ||
| } | ||
| return (implementationContracts || []).filter((contract) => { | ||
| const contractTerms = implementationContractTermSet(graph, contract); | ||
| const score = termScore(contractTerms, requiredTerms).score; | ||
| const capabilityId = refId(contract.capability) || refId(contract.capability_id); | ||
| if (score >= 2) return true; | ||
| if (requiredTerms.size <= 2 && score > 0) return true; | ||
| return Boolean(capabilityId && currentVerificationCapabilityIds.has(String(capabilityId)) && score > 0); | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} graph | ||
| * @param {AnyRecord|null|undefined} focus | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function candidateContractsForUnlinkedTask(graph, focus) { | ||
| if (!graph || focus?.kind !== "task" || !focus.id) { | ||
| return { | ||
| candidates: [], | ||
| suggested_affects: [], | ||
| suggested_verification_refs: [], | ||
| suggested_task_record_edit: null | ||
| }; | ||
| } | ||
| const byId = graphIdMap(graph); | ||
| const task = byId.get(String(focus.id)) || focus; | ||
| const existingAffects = new Set((task.affects || []).map(refId).filter(Boolean)); | ||
| const existingFeatureId = refId(task.feature); | ||
| const existingVerificationRefs = new Set((task.verificationRefs || task.verification_refs || []).map(refId).filter(Boolean)); | ||
| const taskTerms = splitBlockingTerms(taskFeatureTerms(graph, focus)).blocking; | ||
| if (taskTerms.size === 0) { | ||
| return { | ||
| candidates: [], | ||
| suggested_affects: [], | ||
| suggested_verification_refs: [], | ||
| suggested_task_record_edit: null | ||
| }; | ||
| } | ||
| if (!existingFeatureId) { | ||
| /** @type {AnyRecord[]} */ | ||
| const featureCandidates = []; | ||
| for (const feature of graphRecords(graph, "feature").filter(isActiveRecord)) { | ||
| const featureTerms = featureCoreTerms(feature, byId); | ||
| const score = termScore(featureTerms, taskTerms); | ||
| if (score.score === 0) continue; | ||
| const missing = missingTerms(new Set(score.matches), taskTerms); | ||
| if (missing.length > 0) continue; | ||
| featureCandidates.push({ | ||
| record_kind: "feature", | ||
| feature_id: String(feature.id), | ||
| score: score.score, | ||
| primary_score: score.score, | ||
| matched_terms: score.matches, | ||
| primary_matched_terms: score.matches, | ||
| why: `Feature matches current task scope: ${score.matches.join(", ")}.` | ||
| }); | ||
| } | ||
| featureCandidates.sort((a, b) => | ||
| Number(b.score || 0) - Number(a.score || 0) | ||
| || String(a.feature_id || "").localeCompare(String(b.feature_id || "")) | ||
| ); | ||
| const selectedFeature = featureCandidates[0] || null; | ||
| if (selectedFeature) { | ||
| return { | ||
| candidates: [selectedFeature], | ||
| feature_candidates: featureCandidates.slice(0, 5), | ||
| suggested_feature: selectedFeature.feature_id, | ||
| suggested_affects: [], | ||
| suggested_verification_refs: [], | ||
| suggested_task_record_edit: { | ||
| task_id: String(focus.id), | ||
| action: "link_current_task_to_feature", | ||
| snippet: ` feature ${selectedFeature.feature_id}`, | ||
| note: "Edit the current task's feature field to reference the matching feature, then rerun work next. Do not add feature ids to affects; affects is only for concrete model records such as capabilities, endpoints, entities, screens, sections, and verifications." | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| const capabilities = graphRecords(graph, "capability"); | ||
| const capabilityById = new Map(capabilities.map((capability) => [String(capability.id), capability])); | ||
| /** @type {AnyRecord[]} */ | ||
| const candidates = []; | ||
| for (const endpoint of graphRecords(graph, "endpoint").filter(isActiveRecord)) { | ||
| const capabilityId = refId(endpoint.capability); | ||
| if (!capabilityId || existingAffects.has(capabilityId)) continue; | ||
| const capability = capabilityById.get(capabilityId); | ||
| const endpointCoreTerms = textTerms([ | ||
| endpoint.id, | ||
| endpoint.name, | ||
| endpoint.path, | ||
| endpoint.method, | ||
| capability?.id, | ||
| capability?.name | ||
| ]); | ||
| const endpointTerms = textTerms([ | ||
| endpoint.id, | ||
| endpoint.name, | ||
| endpoint.description, | ||
| endpoint.path, | ||
| endpoint.method, | ||
| capability?.id, | ||
| capability?.name, | ||
| capability?.description | ||
| ]); | ||
| const score = termScore(endpointTerms, taskTerms); | ||
| const coreScore = termScore(endpointCoreTerms, taskTerms); | ||
| if (score.score === 0) continue; | ||
| const supportingMatches = score.matches.filter((/** @type {string} */ term) => !coreScore.matches.includes(term)); | ||
| candidates.push({ | ||
| endpoint_id: String(endpoint.id), | ||
| capability_id: capabilityId, | ||
| method: endpoint.method || null, | ||
| path: endpoint.path || null, | ||
| score: score.score, | ||
| primary_score: coreScore.score, | ||
| matched_terms: score.matches, | ||
| primary_matched_terms: coreScore.matches, | ||
| supporting_matched_terms: supportingMatches, | ||
| why: coreScore.matches.length > 0 | ||
| ? `Operation matches current task terms: ${coreScore.matches.join(", ")}${supportingMatches.length > 0 ? `; supporting text also matches ${supportingMatches.join(", ")}` : ""}.` | ||
| : `Supporting text matches current task terms: ${score.matches.join(", ")}.` | ||
| }); | ||
| } | ||
| for (const capability of capabilities.filter(isActiveRecord)) { | ||
| const capabilityId = String(capability.id || ""); | ||
| if (!capabilityId || existingAffects.has(capabilityId)) continue; | ||
| const capabilityTerms = textTerms(directRecordText(capability)); | ||
| const score = termScore(capabilityTerms, taskTerms); | ||
| if (score.score === 0) continue; | ||
| if (candidates.some((candidate) => candidate.capability_id === capabilityId)) continue; | ||
| candidates.push({ | ||
| record_kind: "capability", | ||
| capability_id: capabilityId, | ||
| score: score.score, | ||
| primary_score: score.score, | ||
| matched_terms: score.matches, | ||
| primary_matched_terms: score.matches, | ||
| why: `Capability matches current task terms: ${score.matches.join(", ")}.` | ||
| }); | ||
| } | ||
| for (const kind of ["journey", "screen", "section"]) { | ||
| for (const record of graphRecords(graph, kind).filter(isActiveRecord)) { | ||
| const recordTerms = linkedRecordTerms(record, byId); | ||
| const score = termScore(recordTerms, taskTerms); | ||
| if (score.score === 0) continue; | ||
| const relatedCapabilities = [ | ||
| refId(record.capability), | ||
| ...(record.steps || []).map((/** @type {AnyRecord} */ step) => refId(step?.capability)) | ||
| ].filter(Boolean); | ||
| for (const capabilityId of relatedCapabilities) { | ||
| if (!capabilityId || existingAffects.has(capabilityId)) continue; | ||
| const capability = capabilityById.get(String(capabilityId)); | ||
| if (!capability) continue; | ||
| if (candidates.some((candidate) => candidate.capability_id === capabilityId)) continue; | ||
| const capabilityScore = termScore(textTerms(directRecordText(capability)), taskTerms); | ||
| if (capabilityScore.score === 0) continue; | ||
| candidates.push({ | ||
| record_kind: kind, | ||
| record_id: String(record.id || ""), | ||
| capability_id: String(capabilityId), | ||
| score: capabilityScore.score, | ||
| primary_score: capabilityScore.score, | ||
| matched_terms: capabilityScore.matches, | ||
| primary_matched_terms: capabilityScore.matches, | ||
| why: `${kind} '${record.id}' links a capability that matches current task terms: ${capabilityScore.matches.join(", ")}.` | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| candidates.sort((a, b) => | ||
| (a.endpoint_id ? 0 : 1) - (b.endpoint_id ? 0 : 1) | ||
| || Number(b.primary_score || 0) - Number(a.primary_score || 0) | ||
| || b.score - a.score | ||
| || String(a.endpoint_id || a.capability_id || "").localeCompare(String(b.endpoint_id || b.capability_id || "")) | ||
| ); | ||
| const limitedCandidates = candidates.slice(0, 12); | ||
| const candidateCoveredTerms = new Set(taskAffectsTerms(graph, focus)); | ||
| /** @type {AnyRecord[]} */ | ||
| const selectedCandidates = []; | ||
| const endpointCandidates = limitedCandidates.filter((candidate) => candidate.endpoint_id); | ||
| const nonEndpointCandidates = limitedCandidates.filter((candidate) => !candidate.endpoint_id); | ||
| for (const candidate of endpointCandidates) { | ||
| const selectionTerms = candidateSelectionTerms(candidate); | ||
| const newTerms = selectionTerms | ||
| .filter((/** @type {string} */ term) => !candidateCoveredTerms.has(term)); | ||
| if (newTerms.length === 0) continue; | ||
| selectedCandidates.push(candidate); | ||
| for (const term of newTerms) candidateCoveredTerms.add(term); | ||
| } | ||
| for (const candidate of selectedCandidates.filter((selected) => selected.endpoint_id)) { | ||
| for (const term of candidate.matched_terms || []) { | ||
| if (taskTerms.has(term)) candidateCoveredTerms.add(term); | ||
| } | ||
| } | ||
| const endpointCoverageComplete = missingTerms(candidateCoveredTerms, taskTerms).length === 0; | ||
| if (!endpointCoverageComplete) { | ||
| for (const candidate of nonEndpointCandidates) { | ||
| const selectionTerms = candidateSelectionTerms(candidate); | ||
| const newTerms = selectionTerms | ||
| .filter((/** @type {string} */ term) => !candidateCoveredTerms.has(term)); | ||
| const noEndpointContractCandidates = endpointCandidates.length === 0; | ||
| if (newTerms.length === 0 && !noEndpointContractCandidates) continue; | ||
| if (noEndpointContractCandidates && Number(candidate.score || 0) < 2) continue; | ||
| selectedCandidates.push(candidate); | ||
| for (const term of selectionTerms) candidateCoveredTerms.add(term); | ||
| } | ||
| } | ||
| if (missingTerms(candidateCoveredTerms, taskTerms).length > 0) { | ||
| return { | ||
| candidates: [], | ||
| suggested_affects: [], | ||
| suggested_verification_refs: [], | ||
| suggested_task_record_edit: null | ||
| }; | ||
| } | ||
| /** @type {string[]} */ | ||
| const suggestedAffects = []; | ||
| for (const candidate of selectedCandidates) { | ||
| if (!suggestedAffects.includes(candidate.capability_id)) suggestedAffects.push(candidate.capability_id); | ||
| } | ||
| /** @type {string[]} */ | ||
| const suggestedVerificationRefs = []; | ||
| for (const verification of graphRecords(graph, "verification").filter(isActiveRecord)) { | ||
| if (existingVerificationRefs.has(String(verification.id))) continue; | ||
| const validates = (verification.validates || []).map(refId).filter(Boolean); | ||
| const validatesSuggested = validates.some((/** @type {string} */ id) => suggestedAffects.includes(id)); | ||
| const verificationTerms = textTerms([verification.id, verification.name, verification.description, ...(verification.scenarios || [])]); | ||
| const score = termScore(verificationTerms, taskTerms); | ||
| if (!validatesSuggested && score.score === 0) continue; | ||
| suggestedVerificationRefs.push(String(verification.id)); | ||
| } | ||
| /** @type {string[]} */ | ||
| const patchLines = []; | ||
| if (suggestedAffects.length > 0) patchLines.push(` affects [${suggestedAffects.join(" ")}]`); | ||
| if (suggestedVerificationRefs.length > 0) patchLines.push(` verification_refs [${suggestedVerificationRefs.join(" ")}]`); | ||
| return { | ||
| candidates: selectedCandidates, | ||
| suggested_affects: suggestedAffects, | ||
| suggested_verification_refs: suggestedVerificationRefs, | ||
| suggested_task_record_edit: suggestedAffects.length > 0 ? { | ||
| task_id: String(focus.id), | ||
| action: "link_current_task_to_modeled_capabilities", | ||
| snippet: patchLines.join("\n"), | ||
| note: "Edit the current task's affects and verification_refs to reference the matching model records, then rerun work next." | ||
| } : null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * state: string, | ||
| * activeBucket: string, | ||
| * selectorFragment: string, | ||
| * scaffold: AnyRecord, | ||
| * candidateGuidance?: AnyRecord|null | ||
| * }} input | ||
| * @returns {AnyRecord} | ||
| */ |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { textTokenStats } from "../../../../token-estimate.js"; | ||
| import { buildAgentPayload, diagnosticSummary, fileContextForOutput } from "./implementation-prep-agent-payload.js"; | ||
| import { | ||
| candidateContractsForUnlinkedTask, | ||
| currentFeatureImplementationContracts, | ||
| featureCoverageForTaskWithRequiredOperations | ||
| } from "./implementation-prep-workflow.js"; | ||
| import { buildImplementationWorkflowStep } from "./implementation-prep-workflow-step.js"; | ||
| import { requiredApiOperationsForTask } from "./implementation-prep-required-operations.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const MAX_INCLUDED_FILE_BYTES = 128 * 1024; | ||
| const SELECTOR_LABELS = { | ||
| capabilityId: "capability", | ||
| workflowId: "workflow", | ||
| projectionId: "surface", | ||
| screenId: "screen", | ||
| layoutId: "layout", | ||
| regionId: "region", | ||
| designRealizationSetId: "component_map", | ||
| componentId: "widget", | ||
| entityId: "entity", | ||
| journeyId: "journey", | ||
| surfaceId: "surface", | ||
| domainId: "domain", | ||
| featureId: "feature", | ||
| pitchId: "pitch", | ||
| requirementId: "requirement", | ||
| acceptanceId: "acceptance", | ||
| taskId: "task", | ||
| planId: "plan", | ||
| bugId: "bug", | ||
| documentId: "document", | ||
| modeId: "mode" | ||
| }; | ||
| /** | ||
| * @param {AnyRecord} selectors | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function selectorSummary(selectors) { | ||
| /** @type {AnyRecord} */ | ||
| const summary = {}; | ||
| for (const [key, label] of Object.entries(SELECTOR_LABELS)) { | ||
| const value = selectors[key]; | ||
| if (!value) continue; | ||
| if (label === "surface" && summary.surface) continue; | ||
| summary[label] = value; | ||
| } | ||
| return summary; | ||
| } | ||
| /** | ||
| * @param {string} topogramRoot | ||
| * @returns {string} | ||
| */ | ||
| function projectRootForTopogram(topogramRoot) { | ||
| return path.basename(topogramRoot) === "topo" ? path.dirname(topogramRoot) : topogramRoot; | ||
| } | ||
| /** | ||
| * @param {string} root | ||
| * @param {string} candidate | ||
| * @returns {boolean} | ||
| */ | ||
| function isContained(root, candidate) { | ||
| const relative = path.relative(root, candidate); | ||
| return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); | ||
| } | ||
| /** | ||
| * @param {string} projectRoot | ||
| * @param {string} relativePath | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function readIncludedFile(projectRoot, relativePath) { | ||
| const requested = String(relativePath || "").trim(); | ||
| if (!requested) return { path: requested, ok: false, error: "include-file path is required" }; | ||
| if (path.isAbsolute(requested)) return { path: requested, ok: false, error: "include-file path must be relative" }; | ||
| const absolute = path.resolve(projectRoot, requested); | ||
| if (!isContained(projectRoot, absolute)) return { path: requested, ok: false, error: "include-file path escapes project root" }; | ||
| if (!fs.existsSync(absolute)) return { path: requested, ok: false, error: "file not found" }; | ||
| const stat = fs.lstatSync(absolute); | ||
| if (stat.isSymbolicLink()) return { path: requested, ok: false, error: "symlinks are not supported for included files" }; | ||
| if (!stat.isFile()) return { path: requested, ok: false, error: "path is not a regular file" }; | ||
| const realRoot = fs.realpathSync(projectRoot); | ||
| const realFile = fs.realpathSync(absolute); | ||
| if (!isContained(realRoot, realFile)) return { path: requested, ok: false, error: "include-file real path escapes project root" }; | ||
| if (stat.size > MAX_INCLUDED_FILE_BYTES) { | ||
| return { | ||
| path: requested.split(path.sep).join("/"), | ||
| ok: false, | ||
| bytes: stat.size, | ||
| error: `file exceeds ${MAX_INCLUDED_FILE_BYTES} byte implementation-prep limit` | ||
| }; | ||
| } | ||
| const content = fs.readFileSync(absolute, "utf8"); | ||
| return { | ||
| path: requested.split(path.sep).join("/"), | ||
| ok: true, | ||
| bytes: Buffer.byteLength(content, "utf8"), | ||
| estimated_tokens: textTokenStats(content).estimated_tokens, | ||
| content | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} slice | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function implementationPacketStats(slice) { | ||
| return { | ||
| current_task_id: slice.focus?.kind === "task" ? slice.focus.id : null, | ||
| work_mode: slice.agent_guidance?.mode || slice.packet_profile?.mode || null, | ||
| packet_profile: slice.packet_profile?.profile || null, | ||
| implementation_slice_estimated_tokens: Number(slice.attention_budget?.total?.estimated_tokens || 0), | ||
| omitted_section_count: Array.isArray(slice.omitted_sections) ? slice.omitted_sections.length : 0, | ||
| implementation_contract_count: Array.isArray(slice.implementation_contracts) ? slice.implementation_contracts.length : 0 | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} slice | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function focusSourceRef(slice) { | ||
| const focus = slice?.focus || {}; | ||
| const item = (slice?.work_items || []).find((/** @type {AnyRecord} */ entry) => | ||
| entry?.role === "focus" && entry.id === focus.id && entry.kind === focus.kind | ||
| ); | ||
| const ref = item?.source_ref || null; | ||
| if (!ref?.file) return { status: "unknown" }; | ||
| const file = String(ref.file).startsWith("topo/") ? String(ref.file) : `topo/${ref.file}`; | ||
| return { | ||
| status: "known", | ||
| file, | ||
| line: ref.line || null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} value | ||
| * @returns {number} | ||
| */ | ||
| function estimatedTokens(value) { | ||
| return textTokenStats(JSON.stringify(value || {})).estimated_tokens; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} selectors | ||
| * @returns {string} | ||
| */ | ||
| function selectorCliFragment(selectors) { | ||
| const summary = selectorSummary(selectors); | ||
| const [label, value] = Object.entries(summary).find(([, raw]) => raw) || []; | ||
| if (!label || !value) return ""; | ||
| const cliLabel = label === "component_map" ? "component-map" : label; | ||
| return ` --${cliLabel} ${value}`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} selectors | ||
| * @returns {string} | ||
| */ | ||
| function implementationPrepCliFragment(selectors) { | ||
| const rawSelector = selectorCliFragment(selectors); | ||
| const selector = rawSelector.startsWith(" --mode ") ? "" : rawSelector; | ||
| const mode = selectors.modeId ? ` --mode ${selectors.modeId}` : ""; | ||
| return `${mode}${selector}`; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} projectRoot | ||
| * @returns {{ mode: "advisory"|"required", require_valid_model_for_app_work: boolean, blocked_by_guidance: boolean }} | ||
| */ | ||
| function appWorkPolicy(projectRoot) { | ||
| const policy = { | ||
| mode: /** @type {"advisory"|"required"} */ ("advisory"), | ||
| require_valid_model_for_app_work: false, | ||
| blocked_by_guidance: false | ||
| }; | ||
| if (!projectRoot) return policy; | ||
| const configPath = path.join(projectRoot, "topogram.project.json"); | ||
| if (!fs.existsSync(configPath)) return policy; | ||
| try { | ||
| const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); | ||
| const required = Boolean(parsed?.require_valid_model_for_app_work || parsed?.policy?.require_valid_model_for_app_work); | ||
| return { | ||
| mode: required ? "required" : "advisory", | ||
| require_valid_model_for_app_work: required, | ||
| blocked_by_guidance: false | ||
| }; | ||
| } catch { | ||
| return policy; | ||
| } | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} implementationContracts | ||
| * @param {string|null|undefined} projectRoot | ||
| * @param {{ requireScaffold?: boolean, modeId?: string|null }} [options] | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function scaffoldStatus(implementationContracts, projectRoot, options = {}) { | ||
| const required = options.requireScaffold !== false; | ||
| const modeId = options.modeId || null; | ||
| const advisory = modeId === "handcoded-app-edit" | ||
| ? "handcoded_app_edit" | ||
| : "maintained_app_edit"; | ||
| const advisoryFields = required | ||
| ? { required: true, blocks_implementation: true, mode: modeId } | ||
| : { required: false, blocks_implementation: false, mode: modeId, advisory }; | ||
| const endpointIds = implementationContracts | ||
| .filter((contract) => contract?.kind === "endpoint" && contract.id) | ||
| .map((contract) => String(contract.id)); | ||
| if (endpointIds.length === 0) { | ||
| return { | ||
| target: "node-http-api-scaffold", | ||
| status: "not_applicable", | ||
| current: true, | ||
| endpoint_ids: [], | ||
| ...advisoryFields, | ||
| blocks_implementation: false | ||
| }; | ||
| } | ||
| const manifestPath = projectRoot ? path.join(projectRoot, "topogram-scaffold-manifest.json") : null; | ||
| const serverPath = projectRoot ? path.join(projectRoot, "server.mjs") : null; | ||
| if (!manifestPath || !fs.existsSync(manifestPath)) { | ||
| return { | ||
| target: "node-http-api-scaffold", | ||
| status: required ? "missing" : "advisory_missing", | ||
| current: false, | ||
| ...advisoryFields, | ||
| endpoint_ids: endpointIds, | ||
| missing_endpoint_ids: endpointIds, | ||
| command: "topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --write --out-dir ." | ||
| }; | ||
| } | ||
| try { | ||
| const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); | ||
| const manifestEndpointIds = new Set((manifest.endpoints || []).map( | ||
| /** @param {AnyRecord} endpoint */ | ||
| (endpoint) => String(endpoint.endpoint_id || endpoint.id || "") | ||
| )); | ||
| const serverText = serverPath && fs.existsSync(serverPath) ? fs.readFileSync(serverPath, "utf8") : ""; | ||
| const missingEndpointIds = endpointIds.filter((id) => !manifestEndpointIds.has(id)); | ||
| const missingMarkers = serverText | ||
| ? endpointIds.filter((id) => !serverText.includes(`topogram:endpoint ${id}`)) | ||
| : endpointIds; | ||
| const stale = missingEndpointIds.length > 0 || missingMarkers.length > 0; | ||
| return { | ||
| target: "node-http-api-scaffold", | ||
| status: stale ? (required ? "stale" : "advisory_stale") : "current", | ||
| current: !stale, | ||
| ...advisoryFields, | ||
| blocks_implementation: required && stale, | ||
| endpoint_ids: endpointIds, | ||
| seed_backed_read_count: Number(manifest.seed_backed_read_count || 0), | ||
| todo_count: Number(manifest.todo_count || 0), | ||
| patch_plan: Array.isArray(manifest.patch_plan) ? manifest.patch_plan : [], | ||
| missing_endpoint_ids: missingEndpointIds, | ||
| missing_marker_ids: missingMarkers, | ||
| command: "topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --write --out-dir ." | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| target: "node-http-api-scaffold", | ||
| status: required ? "invalid_manifest" : "advisory_invalid_manifest", | ||
| current: false, | ||
| ...advisoryFields, | ||
| endpoint_ids: endpointIds, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| command: "topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --write --out-dir ." | ||
| }; | ||
| } | ||
| } | ||
| /** | ||
| * @param {string} id | ||
| * @param {string} status | ||
| * @param {string} summary | ||
| * @param {string[]} commands | ||
| * @param {AnyRecord|null} payload | ||
| * @param {string[]} nextQueries | ||
| * @param {boolean} active | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function bucket(id, status, summary, commands = [], payload = null, nextQueries = [], active = false) { | ||
| const result = { | ||
| id, | ||
| status, | ||
| summary, | ||
| commands, | ||
| payload: active ? payload : null, | ||
| next_queries: nextQueries | ||
| }; | ||
| return { | ||
| ...result, | ||
| estimated_tokens: estimatedTokens(result) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * state: string, | ||
| * activeBucket: string, | ||
| * selectors: AnyRecord, | ||
| * checkPayload: AnyRecord|null, | ||
| * slice: AnyRecord|null, | ||
| * implementationContracts: AnyRecord[], | ||
| * implementationPacket: AnyRecord|null, | ||
| * scaffold: AnyRecord, | ||
| * policy: AnyRecord, | ||
| * fileContext: AnyRecord[], | ||
| * candidateGuidance?: AnyRecord|null, | ||
| * featureCoverage?: AnyRecord|null, | ||
| * compact: boolean | ||
| * }} input | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function buildBuckets(input) { | ||
| const rawSelector = selectorCliFragment(input.selectors); | ||
| const selector = rawSelector.startsWith(" --mode ") ? "" : rawSelector; | ||
| const modeFragment = input.selectors.modeId ? ` --mode ${input.selectors.modeId}` : " --mode implementation"; | ||
| const sliceQuery = `topogram query slice ./topo${modeFragment}${selector} --detail standard --json`; | ||
| const prepQuery = `topogram query implementation-prep ./topo${modeFragment}${selector} --detail compact --json`; | ||
| /** @param {string} id */ | ||
| const active = (id) => input.activeBucket === id; | ||
| const proofCommands = input.slice?.proof_plan?.required?.commands || input.slice?.agent_guidance?.proof_commands || ["topogram check . --json"]; | ||
| const writeScope = input.slice?.write_scope || null; | ||
| const implementationPayload = { | ||
| implementation_packet: input.implementationPacket, | ||
| endpoint_contracts: input.implementationContracts, | ||
| write_scope: writeScope, | ||
| file_context: input.fileContext.map((file) => ({ | ||
| path: file.path, | ||
| ok: file.ok, | ||
| bytes: file.bytes || null, | ||
| estimated_tokens: file.estimated_tokens || null, | ||
| error: file.error || null | ||
| })) | ||
| }; | ||
| return [ | ||
| bucket( | ||
| "orient", | ||
| active("orient") ? "next" : "ready", | ||
| input.checkPayload?.ok | ||
| ? "Topogram model validation passed; use the active bucket for the next workflow step." | ||
| : "Topogram model validation failed; repair model source before app implementation.", | ||
| ["topogram check . --json"], | ||
| { | ||
| state: input.state, | ||
| selector: selectorSummary(input.selectors), | ||
| topogram_check: input.checkPayload ? { | ||
| ok: Boolean(input.checkPayload.ok), | ||
| error_count: Array.isArray(input.checkPayload.errors) ? input.checkPayload.errors.length : 0, | ||
| warning_count: Array.isArray(input.checkPayload.warnings) ? input.checkPayload.warnings.length : 0 | ||
| } : null, | ||
| app_work_policy: input.policy | ||
| }, | ||
| [], | ||
| active("orient") | ||
| ), | ||
| bucket( | ||
| "repair_model", | ||
| input.state === "model_invalid" ? "next" : "reference", | ||
| "Use when Topogram validation fails; it groups diagnostics with source-linked repair examples.", | ||
| ["topogram query repair-model ./topo --json", "topogram check . --json", prepQuery], | ||
| { | ||
| errors: Array.isArray(input.checkPayload?.errors) ? input.checkPayload.errors.slice(0, 10) : [] | ||
| }, | ||
| ["topogram query repair-model ./topo --format markdown"], | ||
| active("repair_model") | ||
| ), | ||
| bucket( | ||
| "model_feature", | ||
| input.state === "modeling_needed" || input.state === "task_unlinked" ? "next" : "reference", | ||
| input.state === "task_unlinked" | ||
| ? "The selected task has likely matching model contracts, but it is not linked to them through affects or verification_refs." | ||
| : "Use when the selected implementation task has no current endpoint contracts for the app code being edited.", | ||
| ["topogram query modeling-guide ./topo --mode greenfield-app --format markdown", "topogram check . --json", prepQuery], | ||
| { | ||
| reason: input.state === "task_unlinked" | ||
| ? "Candidate endpoint contracts exist in the model but are not reachable from the current task packet." | ||
| : input.featureCoverage && input.featureCoverage.sufficient === false | ||
| ? "Linked endpoint contracts do not cover the feature terms named by the current task." | ||
| : "No endpoint implementation contracts were found for this task packet.", | ||
| feature_contract_coverage: input.featureCoverage || null, | ||
| candidate_contracts_not_linked_to_task: input.candidateGuidance || null, | ||
| recommended_authoring_order: [ | ||
| "feature_scope", | ||
| "entities_and_rules", | ||
| "capabilities_and_persistence", | ||
| "endpoints_and_seed_data", | ||
| "navpoints_screens_and_journeys", | ||
| "verification", | ||
| "implementation_entry" | ||
| ] | ||
| }, | ||
| ["topogram query modeling-guide ./topo --mode greenfield-app --json", sliceQuery], | ||
| active("model_feature") | ||
| ), | ||
| bucket( | ||
| "prepare_implementation", | ||
| input.state === "implementation_ready" ? "done" : "reference", | ||
| "This packet is the preparation step; use standard/full slice queries only if compact contracts expose a gap.", | ||
| [prepQuery], | ||
| { | ||
| implementation_packet: input.implementationPacket, | ||
| next_queries: [sliceQuery] | ||
| }, | ||
| [sliceQuery], | ||
| active("prepare_implementation") | ||
| ), | ||
| bucket( | ||
| "scaffold", | ||
| input.state === "scaffold_needed" ? "next" : (input.scaffold.current ? "ready" : "reference"), | ||
| !input.scaffold.blocks_implementation && !input.scaffold.current | ||
| ? "Maintained app mode treats scaffold freshness as advisory; use scaffold only when you want a regenerated reference patch." | ||
| : input.scaffold.current | ||
| ? "The node HTTP scaffold is current for the endpoint contracts in this packet." | ||
| : "Generate or refresh the node HTTP scaffold before editing app behavior.", | ||
| input.scaffold.command ? [input.scaffold.command, prepQuery] : [], | ||
| input.scaffold, | ||
| input.scaffold.command ? [input.scaffold.command] : [], | ||
| active("scaffold") | ||
| ), | ||
| bucket( | ||
| "implement", | ||
| input.state === "implementation_ready" ? "next" : "blocked", | ||
| input.state === "implementation_ready" | ||
| ? "Edit the allowed implementation files and marked regions, then run verification." | ||
| : "Wait until the active bucket is complete before app implementation work.", | ||
| ["npm run verify"], | ||
| implementationPayload, | ||
| [sliceQuery], | ||
| active("implement") | ||
| ), | ||
| bucket( | ||
| "verify", | ||
| input.state === "verification_needed" ? "next" : "ready", | ||
| "Run the fastest useful proof first, then broader gates before completing the task.", | ||
| proofCommands, | ||
| { | ||
| proof_plan: input.slice?.proof_plan || null, | ||
| verification_targets: input.slice?.verification_targets || null | ||
| }, | ||
| ["topogram query sdlc-proof-gaps ./topo --json"], | ||
| active("verify") | ||
| ) | ||
| ]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildImplementationPrepQuery(options) { | ||
| const { | ||
| selectors, | ||
| sliceResult, | ||
| checkPayload = null, | ||
| projectRoot = null, | ||
| topogramRoot, | ||
| detailId = "compact", | ||
| includeFiles = [], | ||
| graph = null, | ||
| repairReport = null | ||
| } = options; | ||
| const compact = (detailId || "compact") === "compact"; | ||
| if (!sliceResult?.ok) { | ||
| const policy = appWorkPolicy(projectRoot); | ||
| policy.blocked_by_guidance = true; | ||
| const diagnosticInfo = diagnosticSummary( | ||
| checkPayload || { ok: false, errors: sliceResult?.validation?.errors || [], warnings: sliceResult?.validation?.warnings || [] }, | ||
| repairReport | ||
| ); | ||
| const topogramCheck = { | ||
| ok: false, | ||
| error_count: diagnosticInfo.errorCount, | ||
| warning_count: diagnosticInfo.warningCount, | ||
| diagnostics_count: diagnosticInfo.errorCount + diagnosticInfo.warningCount, | ||
| diagnostics: diagnosticInfo.diagnostics, | ||
| diagnostic_groups: diagnosticInfo.groups | ||
| }; | ||
| const workflow = buildImplementationWorkflowStep({ | ||
| state: "model_invalid", | ||
| activeBucket: "repair_model", | ||
| selectorFragment: implementationPrepCliFragment(selectors), | ||
| scaffold: scaffoldStatus([], projectRoot, { requireScaffold: true, modeId: selectors.modeId || null }), | ||
| candidateGuidance: null | ||
| }); | ||
| const repairBuckets = buildBuckets({ | ||
| state: "model_invalid", | ||
| activeBucket: "repair_model", | ||
| selectors, | ||
| checkPayload: checkPayload || { ok: false, errors: sliceResult?.validation?.errors || [], warnings: sliceResult?.validation?.warnings || [] }, | ||
| slice: null, | ||
| implementationContracts: [], | ||
| implementationPacket: null, | ||
| scaffold: scaffoldStatus([], projectRoot, { requireScaffold: true, modeId: selectors.modeId || null }), | ||
| policy, | ||
| fileContext: [], | ||
| candidateGuidance: null, | ||
| featureCoverage: null, | ||
| compact: true | ||
| }); | ||
| const repairBucket = repairBuckets.find((entry) => entry.id === "repair_model"); | ||
| if (repairBucket) { | ||
| repairBucket.payload = { | ||
| diagnostics: diagnosticInfo.diagnostics, | ||
| diagnostic_groups: diagnosticInfo.groups, | ||
| repair_order: repairReport?.repair_order || diagnosticInfo.groups.map((group) => group.category), | ||
| errors: diagnosticInfo.diagnostics, | ||
| next_commands: repairReport?.next_commands || repairBucket.commands | ||
| }; | ||
| repairBucket.estimated_tokens = estimatedTokens(repairBucket); | ||
| } | ||
| const nextCommands = repairBucket?.commands || []; | ||
| const agentPayload = buildAgentPayload({ | ||
| state: "model_invalid", | ||
| activeBucket: "repair_model", | ||
| selector: selectorSummary(selectors), | ||
| workflow, | ||
| buckets: repairBuckets, | ||
| implementationContracts: [], | ||
| implementationPacket: null, | ||
| scaffold: scaffoldStatus([], projectRoot, { requireScaffold: true, modeId: selectors.modeId || null }), | ||
| policy, | ||
| fileContext: [], | ||
| topogramCheck, | ||
| nextAction: "Repair the Topogram model, rerun topogram check, then run implementation-prep again.", | ||
| nextCommands, | ||
| compact | ||
| }); | ||
| return { | ||
| type: "implementation_prep_query", | ||
| version: 1, | ||
| ok: false, | ||
| stage: "check", | ||
| state: "model_invalid", | ||
| recommended_next_action: "repair_model", | ||
| active_bucket: "repair_model", | ||
| workflow_step: workflow, | ||
| selector: selectorSummary(selectors), | ||
| detail_level: detailId || "compact", | ||
| app_work_policy: policy, | ||
| buckets: repairBuckets, | ||
| topogram_check: topogramCheck, | ||
| diagnostics: diagnosticInfo.diagnostics, | ||
| diagnostic_groups: diagnosticInfo.groups, | ||
| implementation_slice: null, | ||
| implementation_packet: null, | ||
| agent_payload: agentPayload, | ||
| file_context: [], | ||
| next_action: "Repair the Topogram model, rerun topogram check, then run implementation-prep again.", | ||
| next_commands: nextCommands, | ||
| caveats: [ | ||
| "implementation-prep does not modify source files.", | ||
| "The model must validate before app implementation context is considered ready." | ||
| ] | ||
| }; | ||
| } | ||
| const slice = sliceResult.artifact; | ||
| const rootForFiles = projectRoot || projectRootForTopogram(topogramRoot); | ||
| const fileContext = [...new Set(includeFiles)].map((filePath) => readIncludedFile(rootForFiles, filePath)); | ||
| const publicFileContext = fileContextForOutput(fileContext, compact); | ||
| const implementationContracts = Array.isArray(slice.implementation_contracts) ? slice.implementation_contracts : []; | ||
| const requiredOperations = compact | ||
| ? requiredApiOperationsForTask(graph, slice.focus || {}, rootForFiles) | ||
| : []; | ||
| const packetImplementationContracts = compact | ||
| ? currentFeatureImplementationContracts(graph, slice.focus || {}, implementationContracts, requiredOperations) | ||
| : implementationContracts; | ||
| const packetSlice = { | ||
| ...slice, | ||
| implementation_contracts: packetImplementationContracts | ||
| }; | ||
| const implementationPacket = implementationPacketStats(packetSlice); | ||
| const scaffoldOptionalMode = selectors.modeId === "maintained-app-edit" | ||
| || selectors.modeId === "handcoded-app-edit"; | ||
| const scaffold = scaffoldStatus(packetImplementationContracts, rootForFiles, { | ||
| requireScaffold: !scaffoldOptionalMode, | ||
| modeId: selectors.modeId || null | ||
| }); | ||
| const policy = appWorkPolicy(rootForFiles); | ||
| const includesServerFile = fileContext.some((file) => file.path === "server.mjs"); | ||
| const focus = slice.focus || {}; | ||
| const summary = slice.summary || {}; | ||
| const candidateGuidance = candidateContractsForUnlinkedTask(graph, focus); | ||
| const featureCoverage = featureCoverageForTaskWithRequiredOperations(graph, focus, packetImplementationContracts, requiredOperations); | ||
| const featureCoverageGap = focus.kind === "task" | ||
| && summary.work_type === "implementation" | ||
| && packetImplementationContracts.length > 0 | ||
| && includesServerFile | ||
| && featureCoverage.sufficient === false; | ||
| const taskUnlinked = focus.kind === "task" | ||
| && summary.work_type === "implementation" | ||
| && (packetImplementationContracts.length === 0 || featureCoverageGap) | ||
| && includesServerFile | ||
| && Array.isArray(candidateGuidance.candidates) | ||
| && candidateGuidance.candidates.length > 0; | ||
| const needsModeling = focus.kind === "task" | ||
| && summary.work_type === "implementation" | ||
| && (packetImplementationContracts.length === 0 || featureCoverageGap) | ||
| && includesServerFile | ||
| && !taskUnlinked; | ||
| const state = taskUnlinked | ||
| ? "task_unlinked" | ||
| : needsModeling | ||
| ? "modeling_needed" | ||
| : (scaffold.blocks_implementation ? "scaffold_needed" : "implementation_ready"); | ||
| const activeBucket = state === "modeling_needed" || state === "task_unlinked" | ||
| ? "model_feature" | ||
| : state === "scaffold_needed" | ||
| ? "scaffold" | ||
| : "implement"; | ||
| policy.blocked_by_guidance = state !== "implementation_ready"; | ||
| const workflow = buildImplementationWorkflowStep({ | ||
| state, | ||
| activeBucket, | ||
| selectorFragment: implementationPrepCliFragment(selectors), | ||
| scaffold, | ||
| candidateGuidance | ||
| }); | ||
| const buckets = buildBuckets({ | ||
| state, | ||
| activeBucket, | ||
| selectors, | ||
| checkPayload, | ||
| slice, | ||
| implementationContracts: packetImplementationContracts, | ||
| implementationPacket, | ||
| scaffold, | ||
| policy, | ||
| fileContext, | ||
| candidateGuidance, | ||
| featureCoverage, | ||
| compact: (detailId || "compact") === "compact" | ||
| }); | ||
| const active = buckets.find((entry) => entry.id === activeBucket); | ||
| const topogramCheck = { | ||
| ok: true, | ||
| error_count: Array.isArray(checkPayload?.errors) ? checkPayload.errors.length : 0, | ||
| warning_count: Array.isArray(checkPayload?.warnings) ? checkPayload.warnings.length : 0, | ||
| diagnostics_count: (Array.isArray(checkPayload?.errors) ? checkPayload.errors.length : 0) | ||
| + (Array.isArray(checkPayload?.warnings) ? checkPayload.warnings.length : 0) | ||
| }; | ||
| const packet = { | ||
| ...implementationPacket, | ||
| state, | ||
| active_bucket: activeBucket, | ||
| scaffold_status: scaffold.status, | ||
| scaffold_required: scaffold.required !== false, | ||
| scaffold_blocks_implementation: Boolean(scaffold.blocks_implementation), | ||
| bucket_estimated_tokens: active?.estimated_tokens || 0, | ||
| candidate_contract_count: Array.isArray(candidateGuidance.candidates) ? candidateGuidance.candidates.length : 0, | ||
| feature_contract_coverage: featureCoverage, | ||
| all_implementation_contract_count: implementationContracts.length, | ||
| current_feature_contract_count: packetImplementationContracts.length | ||
| }; | ||
| const nextAction = active?.summary || "Edit the allowed implementation files from the packet, then run the proof command named by the slice or project."; | ||
| const nextCommands = active?.commands || []; | ||
| const agentPayload = buildAgentPayload({ | ||
| state, | ||
| activeBucket, | ||
| selector: selectorSummary(selectors), | ||
| workflow, | ||
| buckets, | ||
| implementationContracts: packetImplementationContracts, | ||
| requiredOperations, | ||
| implementationPacket: packet, | ||
| scaffold, | ||
| policy, | ||
| fileContext: publicFileContext, | ||
| topogramCheck, | ||
| nextAction, | ||
| nextCommands, | ||
| compact | ||
| }); | ||
| return { | ||
| type: "implementation_prep_query", | ||
| version: 1, | ||
| ok: true, | ||
| stage: "ready", | ||
| state, | ||
| recommended_next_action: activeBucket, | ||
| active_bucket: activeBucket, | ||
| workflow_step: workflow, | ||
| selector: selectorSummary(selectors), | ||
| detail_level: detailId || "compact", | ||
| app_work_policy: policy, | ||
| buckets, | ||
| topogram_check: topogramCheck, | ||
| focus_source_ref: focusSourceRef(slice), | ||
| implementation_slice: compact ? null : slice, | ||
| implementation_packet: packet, | ||
| agent_payload: agentPayload, | ||
| file_context: publicFileContext, | ||
| next_action: nextAction, | ||
| next_commands: nextCommands, | ||
| caveats: [ | ||
| "implementation-prep is a read-only packet. It validates Topogram source by building the focused implementation slice, but it does not run app tests.", | ||
| "Compact implementation-prep omits the full context slice; use bucket next_queries for drill-down context.", | ||
| "Included file content is read from explicit --include-file paths under the project root and is omitted for missing, non-file, oversized, symlinked, or escaping paths." | ||
| ] | ||
| }; | ||
| } |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { buildCheckCommandPayload } from "../../check.js"; | ||
| import { replaceKnownPathSubstrings, sanitizePublicPayload } from "../../../../public-paths.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const CATEGORY_ORDER = [ | ||
| "parse_error", | ||
| "unknown_statement_kind", | ||
| "screen_kind", | ||
| "missing_layout_reference", | ||
| "region_pattern", | ||
| "layout_slot_shape", | ||
| "section_kind", | ||
| "screen_missing_renders", | ||
| "screen_renders_shape", | ||
| "screen_feature_field", | ||
| "endpoint_success_status", | ||
| "endpoint_request", | ||
| "endpoint_auth", | ||
| "journey_step_id_shape", | ||
| "journey_required_shape", | ||
| "seed_data_shape", | ||
| "verification_shape", | ||
| "task_feature_affects", | ||
| "missing_reference", | ||
| "unknown" | ||
| ]; | ||
| /** @type {Record<string, { expected: string, suggested_action: string, example: string }>} */ | ||
| const CATEGORY_GUIDANCE = { | ||
| parse_error: { | ||
| expected: "Topogram files must parse before semantic validation can run.", | ||
| suggested_action: "Fix the syntax at the reported line, then run topogram check again.", | ||
| example: "domain dom_example {\n name \"Example\"\n description \"Example domain.\"\n status active\n}" | ||
| }, | ||
| unknown_statement_kind: { | ||
| expected: "Use current statement kinds such as navpoint for UI navigation and endpoint for HTTP/API behavior.", | ||
| suggested_action: "Rename removed or unknown statement kinds to the current DSL concept named in the diagnostic.", | ||
| example: "navpoint nav_dashboard {\n name \"Dashboard\"\n description \"Dashboard navigation destination.\"\n path \"/\"\n screen screen_dashboard\n status active\n}" | ||
| }, | ||
| screen_kind: { | ||
| expected: "Every screen must declare a valid semantic kind.", | ||
| suggested_action: "Add kind list, detail, form, dashboard, wizard, settings, auth, or custom to the screen.", | ||
| example: "screen screen_dashboard {\n name \"Dashboard\"\n description \"Operational dashboard.\"\n kind dashboard\n layout layout_dashboard\n title \"Dashboard\"\n status active\n}" | ||
| }, | ||
| missing_layout_reference: { | ||
| expected: "Every screen layout must reference an existing layout record.", | ||
| suggested_action: "Create the missing layout and regions or change the screen layout field to an existing layout id.", | ||
| example: "region region_dashboard_main {\n name \"Dashboard Main\"\n description \"Primary dashboard content.\"\n kind content\n pattern content_region\n placement primary\n status active\n}\n\nlayout layout_dashboard {\n name \"Dashboard\"\n description \"Dashboard layout.\"\n slot { id main uses region_dashboard_main role primary_work_area placement primary }\n status active\n}" | ||
| }, | ||
| region_pattern: { | ||
| expected: "Region pattern and allowed_widget_patterns values must use the normalized UI pattern vocabulary.", | ||
| suggested_action: "Replace generic display words such as list with valid UI pattern ids such as content_region, resource_table, resource_cards, summary_stats, or edit_form.", | ||
| example: "# Instead of: pattern list\nregion region_patient_list {\n name \"Patient List\"\n description \"Patient list content.\"\n kind results\n pattern resource_table\n placement primary\n allowed_widget_patterns [resource_table]\n status active\n}" | ||
| }, | ||
| layout_slot_shape: { | ||
| expected: "Each layout slot block must include symbol fields id and uses, then optional role, placement, viewport, pattern, state, variant, title, density, and style_intent fields.", | ||
| suggested_action: "Replace malformed slot shorthand with a slot block that names the screen-local slot id and references an existing region through uses.", | ||
| example: "# Instead of: slot main region_main\nlayout layout_dashboard {\n name \"Dashboard\"\n description \"Dashboard layout.\"\n slot { id main uses region_main role primary_work_area placement primary }\n status active\n}" | ||
| }, | ||
| section_kind: { | ||
| expected: "Every section must declare a valid section kind such as panel, form, list, dashboard, table, controls, empty_state, or custom.", | ||
| suggested_action: "Add kind to named route-built UI areas that are not reusable widgets yet.", | ||
| example: "section section_dashboard_overview {\n name \"Dashboard Overview\"\n description \"Route-built dashboard overview content.\"\n kind panel\n status active\n}" | ||
| }, | ||
| screen_missing_renders: { | ||
| expected: "Every screen reached by a navpoint must render at least one widget, action, or section into a layout region.", | ||
| suggested_action: "Add a named section or widget, then add a renders block on the screen using region <region> section|widget|action <target> id <render_id>.", | ||
| example: "section section_dashboard_summary {\n name \"Dashboard Summary\"\n description \"Route-built dashboard summary content.\"\n kind dashboard\n status active\n}\n\nscreen screen_dashboard {\n name \"Dashboard\"\n description \"Operational dashboard.\"\n kind dashboard\n layout layout_dashboard\n title \"Dashboard\"\n renders {\n region region_dashboard_main section section_dashboard_summary id dashboard_summary\n }\n status active\n}" | ||
| }, | ||
| screen_renders_shape: { | ||
| expected: "Screen renders entries must place a widget, action, or section into a region.", | ||
| suggested_action: "Rewrite each render entry as region <region> widget|action|section <target> id <render_id>.", | ||
| example: "screen screen_dashboard {\n name \"Dashboard\"\n description \"Operational dashboard.\"\n kind dashboard\n layout layout_dashboard\n title \"Dashboard\"\n renders {\n region main section section_dashboard_summary id dashboard_summary\n }\n status active\n}" | ||
| }, | ||
| screen_feature_field: { | ||
| expected: "Feature scope is modeled on feature records and linked from tasks. Screen records only model what is shown.", | ||
| suggested_action: "Remove feature from the screen. Link product scope through task feature or feature records, then keep screen fields focused on kind, layout, title, regions, and renders.", | ||
| example: "task task_dashboard_implementation {\n name \"Dashboard Implementation\"\n description \"Implement the dashboard feature.\"\n feature feature_dashboard\n affects [screen_dashboard]\n status in-progress\n}" | ||
| }, | ||
| endpoint_success_status: { | ||
| expected: "Endpoint success must be a numeric three-digit HTTP status.", | ||
| suggested_action: "Replace values such as ok with success 200, success 201, or another concrete status code.", | ||
| example: "endpoint endpoint_list_patients {\n name \"List Patients Endpoint\"\n description \"List patients.\"\n method GET\n path \"/api/patients\"\n capability cap_list_patients\n success 200\n auth user\n request none\n status active\n}" | ||
| }, | ||
| endpoint_request: { | ||
| expected: "Endpoint request must be one of body, query, path, or none.", | ||
| suggested_action: "Choose the request placement that matches where the endpoint receives input.", | ||
| example: "endpoint endpoint_create_visit_note {\n name \"Create Visit Note Endpoint\"\n description \"Create a visit note.\"\n method POST\n path \"/api/visit-notes\"\n capability cap_create_visit_note\n success 201\n auth user\n request body\n status active\n}" | ||
| }, | ||
| endpoint_auth: { | ||
| expected: "Endpoint auth must be one of none, user, manager, or admin.", | ||
| suggested_action: "Use endpoint auth mode as runtime policy, then add deeper authorization policy separately when needed.", | ||
| example: "endpoint endpoint_list_patients {\n name \"List Patients Endpoint\"\n description \"List patients.\"\n method GET\n path \"/api/patients\"\n capability cap_list_patients\n success 200\n auth user\n request none\n status active\n}" | ||
| }, | ||
| journey_step_id_shape: { | ||
| expected: "Journey step blocks use named fields. The step id belongs in an id field, not as a bare field name.", | ||
| suggested_action: "Replace step { step_name ... } with step { id step_name intent \"...\" ... }.", | ||
| example: "# Instead of: step { step_review_dashboard intent \"Review dashboard.\" }\njourney journey_daily_operations {\n name \"Daily Operations\"\n description \"Daily current-wave workflow.\"\n status active\n actors [actor_coding_agent]\n goal \"Review work and take the next action.\"\n step { id review_dashboard intent \"Review the dashboard.\" frequency daily }\n}" | ||
| }, | ||
| journey_required_shape: { | ||
| expected: "Journeys require actors, goal, and at least one step block with id and intent.", | ||
| suggested_action: "Add the missing workflow fields before using the journey for layout or navigation planning.", | ||
| example: "journey journey_clinic_visit {\n name \"Clinic Visit\"\n description \"Run the daily clinic visit flow.\"\n status active\n actors [actor_coding_agent]\n goal \"Move a patient from check-in through follow-up.\"\n step { id review_queue intent \"Review the clinician queue.\" frequency daily }\n}" | ||
| }, | ||
| seed_data_shape: { | ||
| expected: "seed_data records must target an entity, declare a fixture purpose, and use record blocks.", | ||
| suggested_action: "Convert ad hoc format/content fields into record blocks with id and field entries.", | ||
| example: "seed_data seed_patients {\n name \"Patient Seeds\"\n description \"Demo patient records.\"\n entity entity_patient\n purpose demo_fixture\n record { id patient_001 field id \"pat-001\" field name \"Avery Stone\" }\n status active\n}" | ||
| }, | ||
| verification_shape: { | ||
| expected: "Verification records require validates, method, scenarios, and status.", | ||
| suggested_action: "Name what is validated, choose a verification method, and list concrete scenarios.", | ||
| example: "verification verification_clinic_ops_smoke {\n name \"Clinic Ops Smoke\"\n description \"Verify clinic ops routes.\"\n validates [cap_list_patients]\n method smoke\n scenarios [list_patients]\n status active\n}" | ||
| }, | ||
| task_feature_affects: { | ||
| expected: "Task feature scope belongs in the task feature field. The affects field is for concrete model records such as capabilities, endpoints, entities, screens, sections, layouts, and themes.", | ||
| suggested_action: "Move feature ids out of affects and into a top-level feature field on the task, then keep affects for the concrete records the task changes.", | ||
| example: "task task_clinic_wave_3_implementation {\n name \"Clinic Wave 3 Implementation\"\n description \"Implement wave 3 behavior.\"\n feature feature_care_gaps_audit_admin_reporting\n affects [cap_list_care_gaps endpoint_list_care_gaps]\n verification_refs [verification_wave_3_smoke]\n status in-progress\n}" | ||
| }, | ||
| missing_reference: { | ||
| expected: "References must point at records that exist in the model and have the expected kind.", | ||
| suggested_action: "Create the missing record or update the reference to the intended existing id.", | ||
| example: "capability cap_list_patients {\n name \"List Patients\"\n description \"List clinic patients.\"\n status active\n}" | ||
| }, | ||
| unknown: { | ||
| expected: "Fix the validation issue named by the diagnostic.", | ||
| suggested_action: "Inspect the source excerpt, compare it with the DSL reference, update topo/**, then run topogram check again.", | ||
| example: "topogram query repair-model ./topo --json" | ||
| } | ||
| }; | ||
| /** @type {Array<{ category: string, patterns: RegExp[] }>} */ | ||
| const CATEGORY_MATCHERS = [ | ||
| { category: "parse_error", patterns: [/parse/i, /unexpected token/i, /unterminated/i, /expected '\}' to close statement body/i] }, | ||
| { category: "unknown_statement_kind", patterns: [/unknown statement kind/i, /statement kind .*renamed/i, /statement kind 'route' was renamed/i] }, | ||
| { category: "screen_kind", patterns: [/missing required field 'kind' on screen/i] }, | ||
| { category: "missing_layout_reference", patterns: [/missing required field 'layout' on screen/i, /references missing layout/i, /must declare a layout/i] }, | ||
| { category: "region_pattern", patterns: [/region .* has invalid UI pattern/i, /region .* has invalid allowed_widget_patterns value/i] }, | ||
| { category: "layout_slot_shape", patterns: [/layout .* slot record requires/i, /layout .* slot field .* must be a symbol/i, /field 'slot' on layout .* must be block/i, /unsupported slot field .* on layout/i] }, | ||
| { category: "section_kind", patterns: [/missing required field 'kind' on section/i, /section .* has invalid kind/i] }, | ||
| { category: "screen_missing_renders", patterns: [/must declare at least one renders entry/i] }, | ||
| { category: "screen_renders_shape", patterns: [/renders entries must start with 'region'/i, /renders .*must use widget, action, or section/i, /renders references invalid region/i, /renders has unknown directive/i, /renders data bindings must use/i, /renders event bindings must use/i] }, | ||
| { category: "screen_feature_field", patterns: [/field 'feature' is not allowed on screen/i] }, | ||
| { category: "endpoint_success_status", patterns: [/endpoint .* success must be a 3-digit HTTP status/i] }, | ||
| { category: "endpoint_request", patterns: [/endpoint .* request must be one of/i] }, | ||
| { category: "endpoint_auth", patterns: [/endpoint .* auth must be one of/i] }, | ||
| { category: "journey_step_id_shape", patterns: [/unsupported 'step' field/i, /journey .* record field 'id' must be a symbol/i, /journey .* step id must match/i] }, | ||
| { category: "journey_required_shape", patterns: [/missing required field '(actors|goal|step)' on journey/i, /journey .* must include at least one step/i, /journey .* step record requires/i] }, | ||
| { category: "seed_data_shape", patterns: [/missing required field '(entity|purpose|record)' on seed_data/i, /field '(format|content)' is not allowed on seed_data/i, /unsupported record field .* on seed_data/i, /seed_data .* record requires/i] }, | ||
| { category: "verification_shape", patterns: [/missing required field '(validates|method|scenarios)' on verification/i, /verification .* must include at least one scenario/i, /invalid verification method/i] }, | ||
| { category: "task_feature_affects", patterns: [/field 'affects' on task .* found feature/i, /missing reference 'feature' in field 'affects' on task/i] }, | ||
| { category: "missing_reference", patterns: [/missing reference/i, /references missing/i] } | ||
| ]; | ||
| /** | ||
| * @param {string|null|undefined} inputPath | ||
| * @returns {Promise<AnyRecord>} | ||
| */ | ||
| export async function buildModelRepairQuery(inputPath) { | ||
| const topogramInputPath = inputPath || "./topo"; | ||
| try { | ||
| const { payload, ast, publicContext } = await buildCheckCommandPayload(topogramInputPath); | ||
| const diagnostics = (payload.errors || []).map((/** @type {AnyRecord} */ error, /** @type {number} */ index) => | ||
| buildDiagnostic(error, index, ast, publicContext) | ||
| ); | ||
| return buildPayload({ | ||
| inputPath: topogramInputPath, | ||
| payload, | ||
| diagnostics, | ||
| publicContext | ||
| }); | ||
| } catch (error) { | ||
| return buildParseFailurePayload(topogramInputPath, error); | ||
| } | ||
| } | ||
| /** | ||
| * @param {{ inputPath: string, payload: AnyRecord, diagnostics: AnyRecord[], publicContext: AnyRecord }} input | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function buildPayload({ inputPath, payload, diagnostics, publicContext }) { | ||
| const groups = groupDiagnostics(diagnostics); | ||
| const presentCategories = new Set(groups.map((group) => group.category)); | ||
| return sanitizePublicPayload({ | ||
| type: "model_repair_query", | ||
| version: 1, | ||
| ok: Boolean(payload.ok), | ||
| source: { | ||
| input_path: inputPath, | ||
| topogram_valid: Boolean(payload.topogram?.valid), | ||
| project_valid: Boolean(payload.project?.valid), | ||
| error_count: diagnostics.length, | ||
| warning_count: Array.isArray(payload.warnings) ? payload.warnings.length : 0 | ||
| }, | ||
| diagnostics, | ||
| groups, | ||
| repair_order: CATEGORY_ORDER.filter((category) => presentCategories.has(category)), | ||
| next_commands: [ | ||
| `topogram query repair-model ${inputPath} --json`, | ||
| `topogram check ${inputPath} --json` | ||
| ], | ||
| caveats: [ | ||
| "This query gives repair guidance only; it does not edit or silently rescue invalid Topogram source.", | ||
| "Fix topo/** source, rerun topogram check, then use focused slices after the model is valid." | ||
| ] | ||
| }, publicContext); | ||
| } | ||
| /** | ||
| * @param {string} inputPath | ||
| * @param {unknown} error | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function buildParseFailurePayload(inputPath, error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| const context = { projectRoot: process.cwd(), workspaceRoot: inputPath, cwd: process.cwd() }; | ||
| const parsed = parseErrorLocation(message); | ||
| const category = "parse_error"; | ||
| const guidance = CATEGORY_GUIDANCE[category]; | ||
| const diagnostic = sanitizePublicPayload({ | ||
| id: "repair_001", | ||
| source: "topogram", | ||
| severity: "error", | ||
| category, | ||
| message, | ||
| file: parsed?.file || null, | ||
| line: parsed?.line || null, | ||
| column: parsed?.column || null, | ||
| statement: null, | ||
| expected: guidance.expected, | ||
| suggested_action: guidance.suggested_action, | ||
| example: guidance.example, | ||
| excerpt: parsed ? sourceExcerpt(path.resolve(inputPath), parsed.file, parsed.line) : null | ||
| }, context); | ||
| return sanitizePublicPayload({ | ||
| type: "model_repair_query", | ||
| version: 1, | ||
| ok: false, | ||
| source: { | ||
| input_path: inputPath, | ||
| topogram_valid: false, | ||
| project_valid: null, | ||
| error_count: 1, | ||
| warning_count: 0 | ||
| }, | ||
| diagnostics: [diagnostic], | ||
| groups: groupDiagnostics([diagnostic]), | ||
| repair_order: [category], | ||
| next_commands: [ | ||
| `topogram query repair-model ${inputPath} --json`, | ||
| `topogram check ${inputPath} --json` | ||
| ], | ||
| caveats: [ | ||
| "Parsing failed before full semantic validation could run.", | ||
| "This query gives repair guidance only; it does not edit or silently rescue invalid Topogram source." | ||
| ] | ||
| }, context); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} error | ||
| * @param {number} index | ||
| * @param {AnyRecord} ast | ||
| * @param {AnyRecord} publicContext | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function buildDiagnostic(error, index, ast, publicContext) { | ||
| const category = classifyDiagnostic(error.message || ""); | ||
| const guidance = CATEGORY_GUIDANCE[category] || CATEGORY_GUIDANCE.unknown; | ||
| const statement = findStatementForLoc(ast, error.loc); | ||
| const file = portableSourceFile(ast.root, error.loc?.file); | ||
| const line = error.loc?.start?.line || null; | ||
| return sanitizePublicPayload({ | ||
| id: `repair_${String(index + 1).padStart(3, "0")}`, | ||
| source: error.source || "topogram", | ||
| severity: "error", | ||
| category, | ||
| message: error.message || String(error), | ||
| file, | ||
| line, | ||
| column: error.loc?.start?.column || null, | ||
| statement, | ||
| expected: guidance.expected, | ||
| suggested_action: guidance.suggested_action, | ||
| example: guidance.example, | ||
| excerpt: sourceExcerpt(ast.root, error.loc?.file, line) | ||
| }, publicContext); | ||
| } | ||
| /** | ||
| * @param {string} message | ||
| * @returns {string} | ||
| */ | ||
| function classifyDiagnostic(message) { | ||
| for (const matcher of CATEGORY_MATCHERS) { | ||
| if (matcher.patterns.some((pattern) => pattern.test(message))) { | ||
| return matcher.category; | ||
| } | ||
| } | ||
| return "unknown"; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} diagnostics | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function groupDiagnostics(diagnostics) { | ||
| const byCategory = new Map(); | ||
| for (const diagnostic of diagnostics) { | ||
| const category = diagnostic.category || "unknown"; | ||
| if (!byCategory.has(category)) { | ||
| const guidance = CATEGORY_GUIDANCE[category] || CATEGORY_GUIDANCE.unknown; | ||
| byCategory.set(category, { | ||
| category, | ||
| count: 0, | ||
| first_file: diagnostic.file || null, | ||
| first_line: diagnostic.line || null, | ||
| expected: guidance.expected, | ||
| suggested_action: guidance.suggested_action | ||
| }); | ||
| } | ||
| byCategory.get(category).count += 1; | ||
| } | ||
| return [...byCategory.values()].sort((left, right) => { | ||
| const leftIndex = CATEGORY_ORDER.indexOf(left.category); | ||
| const rightIndex = CATEGORY_ORDER.indexOf(right.category); | ||
| return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex); | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} ast | ||
| * @param {AnyRecord|null|undefined} loc | ||
| * @returns {{ kind: string, id: string }|null} | ||
| */ | ||
| function findStatementForLoc(ast, loc) { | ||
| if (!loc?.file || !loc?.start?.line) return null; | ||
| const filePath = path.resolve(loc.file); | ||
| const line = Number(loc.start.line); | ||
| for (const file of ast.files || []) { | ||
| if (path.resolve(file.file) !== filePath) continue; | ||
| for (const statement of file.statements || []) { | ||
| if (line >= statement.loc.start.line && line <= statement.loc.end.line) { | ||
| return { kind: statement.kind, id: statement.id }; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {string} root | ||
| * @param {string|null|undefined} file | ||
| * @returns {string|null} | ||
| */ | ||
| function portableSourceFile(root, file) { | ||
| if (!file) return null; | ||
| const resolvedRoot = path.resolve(root); | ||
| const resolvedFile = path.resolve(file); | ||
| const relative = path.relative(resolvedRoot, resolvedFile); | ||
| if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { | ||
| return path.basename(resolvedFile); | ||
| } | ||
| return relative.split(path.sep).join("/"); | ||
| } | ||
| /** | ||
| * @param {string} root | ||
| * @param {string|null|undefined} file | ||
| * @param {number|null|undefined} line | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function sourceExcerpt(root, file, line) { | ||
| if (!file || !line) return null; | ||
| const resolvedRoot = path.resolve(root); | ||
| const resolvedFile = path.resolve(file); | ||
| const relative = path.relative(resolvedRoot, resolvedFile); | ||
| if (relative.startsWith("..") || path.isAbsolute(relative)) return null; | ||
| if (!fs.existsSync(resolvedFile) || !fs.statSync(resolvedFile).isFile()) return null; | ||
| const lines = fs.readFileSync(resolvedFile, "utf8").split(/\r?\n/); | ||
| const startLine = Math.max(1, Number(line) - 2); | ||
| const endLine = Math.min(lines.length, Number(line) + 2); | ||
| return { | ||
| file: portableSourceFile(root, file), | ||
| start_line: startLine, | ||
| end_line: endLine, | ||
| lines: lines.slice(startLine - 1, endLine).map((/** @type {string} */ text, /** @type {number} */ index) => ({ | ||
| line: startLine + index, | ||
| text | ||
| })) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} message | ||
| * @returns {{ file: string, line: number, column: number }|null} | ||
| */ | ||
| function parseErrorLocation(message) { | ||
| const match = String(message).match(/^(.+?):(\d+):(\d+)\s+/); | ||
| if (!match) return null; | ||
| return { | ||
| file: match[1], | ||
| line: Number(match[2]), | ||
| column: Number(match[3]) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} payload | ||
| * @returns {string} | ||
| */ | ||
| export function formatModelRepairMarkdown(payload) { | ||
| const publicPayload = sanitizePublicPayload(payload, { projectRoot: process.cwd(), cwd: process.cwd() }); | ||
| const lines = []; | ||
| lines.push("# Model Repair Packet"); | ||
| lines.push(""); | ||
| lines.push(`Status: ${publicPayload.ok ? "valid" : "invalid"}`); | ||
| lines.push(`Errors: ${publicPayload.source?.error_count || 0}`); | ||
| lines.push(""); | ||
| if ((publicPayload.groups || []).length > 0) { | ||
| lines.push("| Category | Count | First location | Suggested action |"); | ||
| lines.push("| --- | ---: | --- | --- |"); | ||
| for (const group of publicPayload.groups || []) { | ||
| const location = group.first_file ? `${group.first_file}${group.first_line ? `:${group.first_line}` : ""}` : ""; | ||
| lines.push(`| ${escapeMarkdown(group.category)} | ${group.count} | ${escapeMarkdown(location)} | ${escapeMarkdown(group.suggested_action)} |`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| for (const diagnostic of publicPayload.diagnostics || []) { | ||
| const location = diagnostic.file ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}` : "unknown location"; | ||
| lines.push(`## ${diagnostic.id}: ${diagnostic.category}`); | ||
| lines.push(""); | ||
| lines.push(`Location: ${location}`); | ||
| lines.push(`Message: ${replaceKnownPathSubstrings(diagnostic.message || "", { projectRoot: process.cwd(), cwd: process.cwd() })}`); | ||
| lines.push(`Expected: ${diagnostic.expected}`); | ||
| lines.push(`Action: ${diagnostic.suggested_action}`); | ||
| if (diagnostic.statement?.id) { | ||
| lines.push(`Statement: ${diagnostic.statement.kind} ${diagnostic.statement.id}`); | ||
| } | ||
| if (diagnostic.excerpt?.lines?.length) { | ||
| lines.push(""); | ||
| lines.push("```tg"); | ||
| for (const entry of diagnostic.excerpt.lines) { | ||
| lines.push(`${String(entry.line).padStart(4, " ")} | ${entry.text}`); | ||
| } | ||
| lines.push("```"); | ||
| } | ||
| if (diagnostic.example) { | ||
| lines.push(""); | ||
| lines.push("Example:"); | ||
| lines.push(""); | ||
| lines.push("```tg"); | ||
| lines.push(diagnostic.example); | ||
| lines.push("```"); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| if ((publicPayload.next_commands || []).length > 0) { | ||
| lines.push("## Next Commands"); | ||
| for (const command of publicPayload.next_commands) { | ||
| lines.push(`- \`${command}\``); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| if ((publicPayload.caveats || []).length > 0) { | ||
| lines.push("## Caveats"); | ||
| for (const caveat of publicPayload.caveats) { | ||
| lines.push(`- ${caveat}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| return `${lines.join("\n").trim()}\n`; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function escapeMarkdown(value) { | ||
| return String(value || "").replace(/\|/g, "\\|").replace(/\r?\n/g, " "); | ||
| } |
| // @ts-check | ||
| import { | ||
| UI_PATTERN_KINDS, | ||
| UI_REGION_KINDS, | ||
| UI_REGION_PLACEMENTS, | ||
| UI_SCREEN_KINDS, | ||
| UI_SECTION_KINDS | ||
| } from "../../../../validator/kinds.js"; | ||
| import { buildCheckCommandPayload } from "../../check.js"; | ||
| import { replaceKnownPathSubstrings, sanitizePublicPayload } from "../../../../public-paths.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const DEFAULT_MODE = "greenfield-app"; | ||
| const SUPPORTED_MODES = new Set([DEFAULT_MODE]); | ||
| /** | ||
| * @param {Iterable<string>} values | ||
| * @returns {string[]} | ||
| */ | ||
| function sortedValues(values) { | ||
| return [...values].sort((left, right) => left.localeCompare(right)); | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} modeId | ||
| * @returns {string} | ||
| */ | ||
| function normalizeMode(modeId) { | ||
| const mode = modeId || DEFAULT_MODE; | ||
| return SUPPORTED_MODES.has(mode) ? mode : DEFAULT_MODE; | ||
| } | ||
| /** | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function modelingPhases() { | ||
| return [ | ||
| { | ||
| id: "feature_scope", | ||
| title: "Feature Scope", | ||
| goal: "Name the current feature/task boundary before adding graph records.", | ||
| records: ["requirement", "acceptance_criterion", "task"], | ||
| done_when: "The current task has a clear product goal, done condition, and proof target." | ||
| }, | ||
| { | ||
| id: "entities_and_rules", | ||
| title: "Entities And Rules", | ||
| goal: "Model durable domain nouns and constraints first.", | ||
| records: ["domain", "entity", "rule"], | ||
| done_when: "Required fields, keys, relations, and repo/product rules are valid." | ||
| }, | ||
| { | ||
| id: "capabilities_and_persistence", | ||
| title: "Capabilities And Persistence", | ||
| goal: "Describe what the product can do and how it reads or writes domain data.", | ||
| records: ["capability", "persistence"], | ||
| done_when: "Capabilities reference known entities/shapes and persistence behavior is explicit enough for implementation." | ||
| }, | ||
| { | ||
| id: "endpoints_and_seed_data", | ||
| title: "Endpoints And Seed Data", | ||
| goal: "Expose checkable API behavior and fixture/catalog data for the current feature.", | ||
| records: ["endpoint", "seed_data"], | ||
| done_when: "Endpoint method/path/capability/response intent and seed records validate." | ||
| }, | ||
| { | ||
| id: "navpoints_screens_and_journeys", | ||
| title: "Navpoints, Screens, And Journeys", | ||
| goal: "Model how users reach screens and what those screens render.", | ||
| records: ["navpoint", "region", "layout", "section", "screen", "journey"], | ||
| done_when: "UI navigation, screen layout, render placement, and journey steps validate." | ||
| }, | ||
| { | ||
| id: "verification", | ||
| title: "Verification", | ||
| goal: "Attach the smallest proof that says the current feature works.", | ||
| records: ["verification"], | ||
| done_when: "Verification records point at current capabilities or journeys and name a runnable proof shape." | ||
| }, | ||
| { | ||
| id: "implementation_entry", | ||
| title: "Implementation Entry", | ||
| goal: "Switch from authoring the model to implementing from a focused packet.", | ||
| records: ["context_slice"], | ||
| done_when: "topogram check passes and `query slice --mode implementation --task <task-id> --detail compact --json` exposes the contracts to build." | ||
| } | ||
| ]; | ||
| } | ||
| /** | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function guidanceSections() { | ||
| return [ | ||
| { | ||
| id: "minimal_modeling_loop", | ||
| title: "Minimal Modeling Loop", | ||
| purpose: "Model only enough current-wave product structure to support the first implementation pass.", | ||
| guidance: [ | ||
| "Start with domain, entities, capabilities, one screen, one navpoint, one endpoint if an API is needed, one journey, seed_data, and one verification.", | ||
| "Run topogram check after each small group of records.", | ||
| "After the model is valid, use query slice, audit-bundle, and context-savings for implementation context." | ||
| ], | ||
| avoid: [ | ||
| "Do not model future waves before the current wave passes.", | ||
| "Do not put /api/* paths in navpoint records; use endpoint records." | ||
| ], | ||
| example: "topogram query modeling-guide ./topo --mode greenfield-app --format markdown\ntopogram check ./topo --json\ntopogram query slice ./topo --task <task-id> --detail compact --json" | ||
| }, | ||
| { | ||
| id: "domain_entity_capability", | ||
| title: "Domain, Entity, Capability", | ||
| purpose: "Name the product area, durable nouns, and user/system operations.", | ||
| guidance: [ | ||
| "Use entity for durable domain objects.", | ||
| "Use capability for operations the app must perform.", | ||
| "Keep field names portable Topogram identifiers." | ||
| ], | ||
| example: `domain dom_operations { | ||
| name "Operations" | ||
| description "Operational workflow for the current app wave." | ||
| status active | ||
| } | ||
| entity entity_patient { | ||
| name "Patient" | ||
| description "A person receiving service." | ||
| field id string required true | ||
| field name string required true | ||
| field status string required true | ||
| key primary [id] | ||
| status active | ||
| } | ||
| capability cap_list_patients { | ||
| name "List Patients" | ||
| description "Show the current patient list." | ||
| reads [entity_patient] | ||
| status active | ||
| }` | ||
| }, | ||
| { | ||
| id: "layout_region_section_screen", | ||
| title: "Region, Layout, Section, Screen Renders", | ||
| purpose: "Describe what is shown without choosing framework components.", | ||
| guidance: [ | ||
| `Region kind values include ${sortedValues(UI_REGION_KINDS).slice(0, 8).join(", ")}.`, | ||
| `Region pattern values include ${sortedValues(UI_PATTERN_KINDS).slice(0, 8).join(", ")}.`, | ||
| `Section kind values include ${sortedValues(UI_SECTION_KINDS).slice(0, 8).join(", ")}.`, | ||
| "A layout slot record must include id and uses. Screen renders entries place a widget, action, or section into a layout slot." | ||
| ], | ||
| example: `region region_main { | ||
| name "Main Content" | ||
| description "Primary work area." | ||
| kind content | ||
| pattern content_region | ||
| placement primary | ||
| status active | ||
| } | ||
| layout layout_dashboard { | ||
| name "Dashboard Layout" | ||
| description "Single-region dashboard layout." | ||
| slot { id main uses region_main role primary_work_area placement primary } | ||
| status active | ||
| } | ||
| section section_dashboard_summary { | ||
| name "Dashboard Summary" | ||
| description "Summary content for the current wave." | ||
| kind panel | ||
| status active | ||
| } | ||
| screen screen_dashboard { | ||
| name "Dashboard" | ||
| description "Current operational dashboard." | ||
| kind dashboard | ||
| layout layout_dashboard | ||
| title "Dashboard" | ||
| renders { | ||
| region main section section_dashboard_summary id dashboard_summary | ||
| } | ||
| status active | ||
| }` | ||
| }, | ||
| { | ||
| id: "navpoint_endpoint_boundary", | ||
| title: "Navpoint vs Endpoint", | ||
| purpose: "Separate UI navigation from HTTP/API behavior.", | ||
| guidance: [ | ||
| "Use navpoint for a user-visible destination that renders a screen.", | ||
| "Use endpoint for an HTTP/API operation that realizes a capability.", | ||
| "Endpoint ids should be operation-shaped, such as endpoint_list_patients." | ||
| ], | ||
| example: `navpoint nav_dashboard { | ||
| name "Dashboard Navigation" | ||
| description "Open the dashboard screen." | ||
| path "/" | ||
| screen screen_dashboard | ||
| loader cap_list_patients | ||
| auth user | ||
| status active | ||
| } | ||
| endpoint endpoint_list_patients { | ||
| name "List Patients Endpoint" | ||
| description "Read patient records." | ||
| method GET | ||
| path "/api/patients" | ||
| capability cap_list_patients | ||
| success 200 | ||
| auth user | ||
| request none | ||
| status active | ||
| }` | ||
| }, | ||
| { | ||
| id: "journey_steps", | ||
| title: "Journey Step", | ||
| purpose: "Make the current user workflow explicit enough to drive navigation and layout decisions.", | ||
| guidance: [ | ||
| "Use one journey for the current wave's happy path.", | ||
| "Each step block needs id and intent; add screen, capability, and frequency when known.", | ||
| "Valid frequency values are daily, weekly, occasional, setup, and admin." | ||
| ], | ||
| example: `journey journey_daily_operations { | ||
| name "Daily Operations" | ||
| description "Complete the daily operational review." | ||
| status active | ||
| primary_frequency daily | ||
| actors [actor_coding_agent] | ||
| goal "Review current work and take the next action." | ||
| step { id review_dashboard intent "Review the current dashboard." screen screen_dashboard capability cap_list_patients frequency daily } | ||
| }` | ||
| }, | ||
| { | ||
| id: "seed_data", | ||
| title: "Seed Data", | ||
| purpose: "Turn catalog/demo/test records in the product brief into model-owned fixtures.", | ||
| guidance: [ | ||
| "Use seed_data when sample records are domain facts for the prototype or tests.", | ||
| "Use purpose catalog_fixture, demo_fixture, or test_fixture.", | ||
| "Record fields must reference fields on the target entity." | ||
| ], | ||
| example: `seed_data seed_patients { | ||
| name "Patient Seeds" | ||
| description "Demo records for the current wave." | ||
| entity entity_patient | ||
| purpose demo_fixture | ||
| record { | ||
| id patient_001 | ||
| field id "pat-001" | ||
| field name "Avery Stone" | ||
| field status "waiting" | ||
| } | ||
| status active | ||
| }` | ||
| }, | ||
| { | ||
| id: "verification", | ||
| title: "Verification", | ||
| purpose: "Name the proof that says the current wave works.", | ||
| guidance: [ | ||
| "Use verification records to connect product behavior to proof commands or scenarios.", | ||
| "Keep scenarios concrete and current-wave-sized.", | ||
| "Use method smoke, runtime, contract, journey, or manual." | ||
| ], | ||
| example: `verification verification_current_wave_smoke { | ||
| name "Current Wave Smoke" | ||
| description "Prove the current wave can list patients." | ||
| validates [cap_list_patients] | ||
| method smoke | ||
| scenarios [list_patients] | ||
| status active | ||
| }` | ||
| } | ||
| ]; | ||
| } | ||
| /** | ||
| * @param {string} inputPath | ||
| * @returns {Promise<AnyRecord>} | ||
| */ | ||
| async function sourceSummary(inputPath) { | ||
| try { | ||
| const { payload, publicContext } = await buildCheckCommandPayload(inputPath); | ||
| return sanitizePublicPayload({ | ||
| input_path: inputPath, | ||
| check_available: true, | ||
| topogram_valid: Boolean(payload.topogram?.valid), | ||
| project_valid: Boolean(payload.project?.valid), | ||
| files: payload.topogram?.files ?? null, | ||
| statements: payload.topogram?.statements ?? null, | ||
| errors: Array.isArray(payload.errors) ? payload.errors.length : 0, | ||
| warnings: Array.isArray(payload.warnings) ? payload.warnings.length : 0, | ||
| sparse: Number(payload.topogram?.statements || 0) < 8 | ||
| }, publicContext); | ||
| } catch (error) { | ||
| const context = { projectRoot: process.cwd(), workspaceRoot: inputPath, cwd: process.cwd() }; | ||
| return sanitizePublicPayload({ | ||
| input_path: inputPath, | ||
| check_available: false, | ||
| topogram_valid: false, | ||
| project_valid: null, | ||
| files: null, | ||
| statements: null, | ||
| errors: 1, | ||
| warnings: 0, | ||
| sparse: true, | ||
| check_error: replaceKnownPathSubstrings(error instanceof Error ? error.message : String(error), context) | ||
| }, context); | ||
| } | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} inputPath | ||
| * @param {string|null|undefined} modeId | ||
| * @returns {Promise<AnyRecord>} | ||
| */ | ||
| export async function buildModelingGuideQuery(inputPath, modeId = null) { | ||
| const topogramInputPath = inputPath || "./topo"; | ||
| const mode = normalizeMode(modeId); | ||
| const source = await sourceSummary(topogramInputPath); | ||
| const publicContext = { projectRoot: process.cwd(), workspaceRoot: topogramInputPath, cwd: process.cwd() }; | ||
| const recommended = [ | ||
| "topogram query modeling-guide ./topo --mode greenfield-app --format markdown", | ||
| "topogram check ./topo --json", | ||
| "topogram query repair-model ./topo --format markdown", | ||
| "topogram query slice ./topo --mode implementation --task <task-id> --detail compact --json" | ||
| ]; | ||
| const phases = modelingPhases(); | ||
| return sanitizePublicPayload({ | ||
| type: "modeling_guide_query", | ||
| version: 1, | ||
| mode, | ||
| supported_modes: sortedValues(SUPPORTED_MODES), | ||
| source, | ||
| current_wave_policy: { | ||
| rule: "Model the smallest valid current-wave slice first, then implement. Expand the model only when the next wave needs it.", | ||
| first_pass_records: ["domain", "entity", "capability", "region", "layout", "section", "screen", "navpoint", "endpoint", "journey", "seed_data", "verification"], | ||
| stop_condition: "topogram check passes and the focused slice/audit bundle points at the implementation work." | ||
| }, | ||
| modeling_phases: phases, | ||
| recommended_authoring_order: phases.map((phase) => phase.id), | ||
| vocabulary: { | ||
| screen_kinds: sortedValues(UI_SCREEN_KINDS), | ||
| region_kinds: sortedValues(UI_REGION_KINDS), | ||
| region_patterns: sortedValues(UI_PATTERN_KINDS), | ||
| region_placements: sortedValues(UI_REGION_PLACEMENTS), | ||
| section_kinds: sortedValues(UI_SECTION_KINDS), | ||
| journey_frequencies: ["daily", "weekly", "occasional", "setup", "admin"], | ||
| endpoint_auth_modes: ["none", "user", "manager", "admin"], | ||
| endpoint_request_placements: ["none", "body", "query", "path"], | ||
| seed_data_purposes: ["catalog_fixture", "demo_fixture", "test_fixture"] | ||
| }, | ||
| sections: guidanceSections(), | ||
| next_commands: recommended, | ||
| caveats: [ | ||
| "This guide is product-agnostic and read-only; it does not validate or edit the model.", | ||
| "When topogram check fails, use repair-model for source-linked recovery guidance." | ||
| ] | ||
| }, publicContext); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} payload | ||
| * @returns {string} | ||
| */ | ||
| export function formatModelingGuideMarkdown(payload) { | ||
| const publicPayload = sanitizePublicPayload(payload, { projectRoot: process.cwd(), cwd: process.cwd() }); | ||
| const lines = []; | ||
| lines.push("# Topogram Modeling Guide"); | ||
| lines.push(""); | ||
| lines.push(`Mode: ${publicPayload.mode}`); | ||
| lines.push(`Source: ${publicPayload.source?.input_path || "./topo"}`); | ||
| lines.push(`Check: ${publicPayload.source?.check_available ? "available" : "unavailable"}`); | ||
| lines.push(`Topogram valid: ${publicPayload.source?.topogram_valid === true ? "yes" : "no"}`); | ||
| lines.push(`Statements: ${publicPayload.source?.statements ?? "unknown"}`); | ||
| lines.push(""); | ||
| lines.push("## Current-Wave Policy"); | ||
| lines.push(publicPayload.current_wave_policy?.rule || ""); | ||
| lines.push(""); | ||
| lines.push(`Stop when: ${publicPayload.current_wave_policy?.stop_condition || ""}`); | ||
| lines.push(""); | ||
| lines.push("## Recommended Authoring Order"); | ||
| lines.push(""); | ||
| for (const phase of publicPayload.modeling_phases || []) { | ||
| lines.push(`- **${phase.title}** (\`${phase.id}\`): ${phase.goal || ""}`); | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Minimal Loop"); | ||
| for (const command of publicPayload.next_commands || []) { | ||
| lines.push(`- \`${command}\``); | ||
| } | ||
| lines.push(""); | ||
| for (const section of publicPayload.sections || []) { | ||
| lines.push(`## ${section.title}`); | ||
| lines.push(""); | ||
| lines.push(section.purpose || ""); | ||
| if ((section.guidance || []).length > 0) { | ||
| lines.push(""); | ||
| for (const item of section.guidance) { | ||
| lines.push(`- ${item}`); | ||
| } | ||
| } | ||
| if ((section.avoid || []).length > 0) { | ||
| lines.push(""); | ||
| lines.push("Avoid:"); | ||
| for (const item of section.avoid) { | ||
| lines.push(`- ${item}`); | ||
| } | ||
| } | ||
| if (section.example) { | ||
| lines.push(""); | ||
| lines.push("```tg"); | ||
| lines.push(section.example); | ||
| lines.push("```"); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| lines.push("## Vocabulary"); | ||
| lines.push(""); | ||
| lines.push(`Region patterns: ${escapeMarkdown((publicPayload.vocabulary?.region_patterns || []).join(", "))}`); | ||
| lines.push(`Section kinds: ${escapeMarkdown((publicPayload.vocabulary?.section_kinds || []).join(", "))}`); | ||
| lines.push(`Journey frequencies: ${escapeMarkdown((publicPayload.vocabulary?.journey_frequencies || []).join(", "))}`); | ||
| lines.push(""); | ||
| if ((publicPayload.caveats || []).length > 0) { | ||
| lines.push("## Caveats"); | ||
| for (const caveat of publicPayload.caveats) { | ||
| lines.push(`- ${caveat}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| return `${lines.join("\n").trim()}\n`; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function escapeMarkdown(value) { | ||
| return String(value || "").replace(/\|/g, "\\|").replace(/\r?\n/g, " "); | ||
| } |
| export function runTraceCommand(context: { | ||
| commandArgs: Record<string, any>; | ||
| args: string[]; | ||
| json: boolean; | ||
| outputFormat?: string | null; | ||
| }): number; | ||
| export function printTraceHelp(): void; |
| // @ts-check | ||
| import path from "node:path"; | ||
| import { stablePublicStringify } from "../../public-paths.js"; | ||
| import { | ||
| buildTraceAnalysis, | ||
| compareTraceAnalyses, | ||
| formatTraceMarkdown | ||
| } from "../../trace/analyze.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord} payload | ||
| * @param {boolean} json | ||
| * @returns {void} | ||
| */ | ||
| function printJsonOrMarkdown(payload, json) { | ||
| if (json) { | ||
| console.log(stablePublicStringify(payload, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| console.log(formatTraceMarkdown(payload)); | ||
| } | ||
| } | ||
| /** | ||
| * @param {{ commandArgs: AnyRecord, args: string[], json: boolean, outputFormat?: string|null }} context | ||
| * @returns {number} | ||
| */ | ||
| export function runTraceCommand(context) { | ||
| const { commandArgs, args, json, outputFormat } = context; | ||
| const auditBundleIndex = args.indexOf("--audit-bundle"); | ||
| const auditBundlePath = auditBundleIndex >= 0 ? args[auditBundleIndex + 1] || null : null; | ||
| try { | ||
| if (commandArgs.traceCommand === "analyze") { | ||
| const analysis = buildTraceAnalysis(path.resolve(commandArgs.inputPath), { auditBundlePath }); | ||
| printJsonOrMarkdown(analysis, json); | ||
| return 0; | ||
| } | ||
| if (commandArgs.traceCommand === "report") { | ||
| const analysis = buildTraceAnalysis(path.resolve(commandArgs.inputPath), { auditBundlePath }); | ||
| if (json && outputFormat !== "markdown") { | ||
| console.log(stablePublicStringify(analysis, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| console.log(formatTraceMarkdown(analysis)); | ||
| } | ||
| return 0; | ||
| } | ||
| if (commandArgs.traceCommand === "compare") { | ||
| const comparison = compareTraceAnalyses(path.resolve(commandArgs.inputPath), path.resolve(commandArgs.comparePath)); | ||
| if (json) { | ||
| console.log(stablePublicStringify(comparison, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| console.log(`# Topogram Trace Compare\n\nLeft: ${comparison.left.run_id}\nRight: ${comparison.right.run_id}\n\nTopogram token delta: ${comparison.delta.topogram_tokens_right_minus_left}.\nAttention smell delta: ${comparison.delta.attention_smells_right_minus_left}.\n`); | ||
| } | ||
| return 0; | ||
| } | ||
| console.error("Unsupported trace command. Use analyze, report, or compare."); | ||
| return 1; | ||
| } catch (error) { | ||
| if (json) { | ||
| console.log(stablePublicStringify({ ok: false, error: error.message }, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| console.error(error.message); | ||
| } | ||
| return 1; | ||
| } | ||
| } | ||
| export function printTraceHelp() { | ||
| console.log("Usage: topogram trace analyze <run-dir> [--audit-bundle <bundle-dir>] [--json]"); | ||
| console.log(" or: topogram trace report <run-dir> [--audit-bundle <bundle-dir>] [--format markdown]"); | ||
| console.log(" or: topogram trace compare <run-a> <run-b> [--json]"); | ||
| console.log(""); | ||
| console.log("Analyzes agent/human work run artifacts against Topogram workflow evidence."); | ||
| } |
| // @ts-check | ||
| import { combinedPatchReadyForTargets } from "./patch-ready.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function actionId(value) { | ||
| return String(value || "action") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "_") | ||
| .replace(/^_+|_+$/g, "") || "action"; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @param {AnyRecord|null|undefined} patch | ||
| * @param {string} state | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function modelPatchActions(targets, patch, state) { | ||
| if (state !== "model_missing") return []; | ||
| const modelTargets = recordArray(targets).filter((target) => target.kind === "model_record" && target.file && target.snippet); | ||
| const taskTarget = recordArray(targets).find((target) => target.kind === "task_record_edit" && target.file && target.snippet); | ||
| if (modelTargets.length === 0 && !taskTarget) return []; | ||
| /** @type {Map<string, string[]>} */ | ||
| const byFile = new Map(); | ||
| for (const target of modelTargets) { | ||
| const snippets = byFile.get(target.file) || []; | ||
| snippets.push(String(target.snippet).trim()); | ||
| byFile.set(target.file, snippets); | ||
| } | ||
| /** @type {AnyRecord[]} */ | ||
| const toolCalls = []; | ||
| for (const [file, snippets] of byFile.entries()) { | ||
| toolCalls.push({ | ||
| tool: "write_file", | ||
| args: { | ||
| path: file, | ||
| content: `${snippets.join("\n\n")}\n` | ||
| }, | ||
| must_not_exist: true, | ||
| target_ids: modelTargets.filter((target) => target.file === file).map((target) => target.record_id).filter(Boolean) | ||
| }); | ||
| } | ||
| const taskId = String(taskTarget?.task_id || patch?.task_id || ""); | ||
| if (taskTarget?.file && taskTarget?.snippet && taskId) { | ||
| toolCalls.push({ | ||
| tool: "replace_file_text", | ||
| args: { | ||
| path: taskTarget.file, | ||
| search: `task ${taskId} {`, | ||
| replacement: `task ${taskId} {\n${String(taskTarget.snippet).replace(/\n+$/, "")}` | ||
| }, | ||
| target_ids: [taskId, taskTarget.record_id].filter(Boolean), | ||
| mode: "insert_task_feature_field" | ||
| }); | ||
| } | ||
| return [{ | ||
| id: `action_model_${actionId(taskId || modelTargets[0]?.record_id || "current_feature")}`, | ||
| kind: "batch_model_patch", | ||
| summary: "Create the current-feature model records and link the task in one action.", | ||
| allowed_state: state, | ||
| preferred: true, | ||
| tool_calls: toolCalls, | ||
| success_condition: "The model records exist, the task has the feature field, and the next work packet advances past model_missing.", | ||
| runs_public_check: false, | ||
| failure_behavior: "No partial writes should be committed if any target file or exact task anchor is unsafe." | ||
| }]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @param {string} state | ||
| * @param {AnyRecord[]} [experienceTargets] | ||
| * @param {AnyRecord[]} [fileContext] | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function codePatchActions(targets, state, experienceTargets = [], fileContext = []) { | ||
| if (state !== "code_edit_ready") return []; | ||
| const combined = combinedPatchReadyForTargets(targets, { experienceTargets, fileContext }); | ||
| if (!combined) return []; | ||
| return [{ | ||
| id: "action_patch_current_endpoints", | ||
| kind: "batch_app_patch", | ||
| summary: `Patch ${combined.endpoint_ids.length || targets.length} current endpoint handler(s)${combined.experience_target_ids?.length ? " plus dashboard UI" : ""} and run the public check.`, | ||
| allowed_state: state, | ||
| preferred: true, | ||
| tool_calls: combined.tool_calls, | ||
| endpoint_ids: combined.endpoint_ids, | ||
| experience_target_ids: combined.experience_target_ids || [], | ||
| patch_ready: combined, | ||
| success_condition: "All current endpoint handlers are patched and run_public_check passes.", | ||
| runs_public_check: true, | ||
| failure_behavior: "Validate every search anchor before writing; if any anchor is missing or non-unique, fail without partial edits." | ||
| }]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} scaffold | ||
| * @param {string} state | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function scaffoldActions(scaffold, state) { | ||
| if (state !== "scaffold_needed") return []; | ||
| return [{ | ||
| id: "action_scaffold_patch_and_check", | ||
| kind: "scaffold_patch_and_check", | ||
| summary: "Regenerate the node-http API scaffold, apply scaffold TODO patches when available, and run the public check.", | ||
| allowed_state: state, | ||
| preferred: true, | ||
| tool_calls: [ | ||
| { tool: "run_topogram", args: { command: "scaffold" } }, | ||
| { tool: "apply_scaffold_patch_targets", args: { source: "latest_scaffold_result" } }, | ||
| { tool: "run_public_check", args: {} } | ||
| ], | ||
| success_condition: "Scaffold markers are current, ready scaffold TODO regions are patched, and run_public_check passes.", | ||
| runs_public_check: true, | ||
| failure_behavior: "If scaffold generation or any patch target fails, stop and return the failing target without appending manual edits.", | ||
| scaffold_status: scaffold?.status || null | ||
| }]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} input | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function workNextActions(input) { | ||
| const { state, targets, codeTargets, patch, scaffold, experienceTargets, fileContext } = input; | ||
| return [ | ||
| ...modelPatchActions(targets, patch, state), | ||
| ...scaffoldActions(scaffold, state), | ||
| ...codePatchActions(codeTargets, state, experienceTargets, fileContext) | ||
| ]; | ||
| } |
| // @ts-check | ||
| import { actionSummary, targetSummary } from "./summaries.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** @type {Record<string, { stack: string, ownership: string }>} */ | ||
| const IMPLEMENTER_REGISTRY = { | ||
| "node-http-maintained": { | ||
| stack: "node-http", | ||
| ownership: "maintained" | ||
| }, | ||
| "generic-maintained": { | ||
| stack: "unknown", | ||
| ownership: "maintained" | ||
| } | ||
| }; | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function uxEvidence(value) { | ||
| const source = value && typeof value === "object" && !Array.isArray(value) ? /** @type {AnyRecord} */ (value) : {}; | ||
| return { | ||
| visible_actions: recordArray(source.visible_actions).map(String), | ||
| state_copy: recordArray(source.state_copy).map(String), | ||
| role_affordances: recordArray(source.role_affordances).map(String) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} seedSummaries | ||
| * @param {string|null|undefined} endpointId | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function seedsForEndpoint(seedSummaries, endpointId) { | ||
| return recordArray(seedSummaries) | ||
| .filter((seed) => seed.endpoint_id === endpointId) | ||
| .map((seed) => ({ | ||
| seed_id: seed.seed_id || null, | ||
| entity_id: seed.entity_id || null, | ||
| purpose: seed.purpose || null, | ||
| record_count: seed.record_count || 0 | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} contracts | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function operationTargets(contracts) { | ||
| const seedSummaries = recordArray(contracts.seed_summaries); | ||
| return recordArray(contracts.endpoint_contracts).map((contract) => ({ | ||
| kind: "endpoint_operation", | ||
| endpoint_id: contract.id || null, | ||
| capability_id: contract.capability_id || null, | ||
| method: contract.method || null, | ||
| path: contract.path || null, | ||
| expected_status: contract.success_status || 200, | ||
| response: contract.response || null, | ||
| seed_summaries: seedsForEndpoint(seedSummaries, contract.id), | ||
| verification_ids: Array.isArray(contract.verification_ids) ? contract.verification_ids : [], | ||
| implementation_intent: "Implement this operation in the selected app stack while preserving the modeled response and proof expectations." | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function experienceTargets(fileContext) { | ||
| /** @type {AnyRecord[]} */ | ||
| const checks = []; | ||
| for (const file of recordArray(fileContext)) { | ||
| for (const check of recordArray(file?.experience_checks)) { | ||
| checks.push({ ...check, source_file: file.path || null }); | ||
| } | ||
| } | ||
| return checks.map((check) => ({ | ||
| kind: "dashboard_experience", | ||
| id: check.id ? `experience_${check.id}` : "experience_dashboard", | ||
| source: check.source || "product_ui_contract", | ||
| source_file: check.source_file || null, | ||
| wave: check.wave || null, | ||
| path: check.path || "/", | ||
| expected_status: check.expect_status || 200, | ||
| expected_content_type: check.expect_content_type || "text/html", | ||
| required_text: recordArray(check.required_text).map(String), | ||
| forbidden_text: recordArray(check.forbidden_text).map(String), | ||
| required_tags: recordArray(check.required_tags).map(String), | ||
| min_sections: Number(check.min_sections || 0), | ||
| ux_evidence: uxEvidence(check.ux_evidence), | ||
| verification_ids: [check.id].filter(Boolean), | ||
| implementation_intent: "Render product-facing UI evidence for this route while preserving role visibility and forbidden placeholder constraints." | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} input | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function buildImplementer(input) { | ||
| const { mode, state, codeTargets, actions, fileContext, projectCommands, implementerId, appState, experienceTargets: uiTargets } = input; | ||
| if (state !== "code_edit_ready") return null; | ||
| const actionList = recordArray(actions).filter((action) => action.kind === "batch_app_patch"); | ||
| const targetList = recordArray(codeTargets); | ||
| const hasNodeHttpFile = recordArray(fileContext).some((file) => | ||
| file?.path === "server.mjs" | ||
| || recordArray(file?.relevant_lines).some((line) => /node:http|createServer|listen\(/.test(String(line.text || ""))) | ||
| ); | ||
| const hasExecutableAction = actionList.some((action) => recordArray(action.tool_calls).length > 0); | ||
| const detectedId = hasNodeHttpFile || /handcoded|maintained/.test(String(mode || "")) | ||
| ? "node-http-maintained" | ||
| : "generic-maintained"; | ||
| const requestedId = String(implementerId || "").trim(); | ||
| const id = requestedId && IMPLEMENTER_REGISTRY[requestedId] ? requestedId : detectedId; | ||
| const metadata = IMPLEMENTER_REGISTRY[id] || IMPLEMENTER_REGISTRY["generic-maintained"]; | ||
| const normalizedAppState = String(appState || "unknown"); | ||
| const confidence = hasExecutableAction && id === "node-http-maintained" && hasNodeHttpFile | ||
| ? "high" | ||
| : (targetList.length > 0 ? "low" : "none"); | ||
| const limitations = []; | ||
| if (requestedId && !IMPLEMENTER_REGISTRY[requestedId]) { | ||
| limitations.push(`Requested implementer '${requestedId}' is not registered; falling back to ${id}.`); | ||
| } | ||
| if (!hasNodeHttpFile) limitations.push("No vanilla Node HTTP app entrypoint was recognized in the included file context."); | ||
| if (!hasExecutableAction) { | ||
| limitations.push("No safe executable patch action was available for the current operation targets."); | ||
| const missingAnchors = targetList | ||
| .filter((target) => target?.patch_ready?.mode === "manual_patch_required") | ||
| .map((target) => `${target.method || "GET"} ${target.api_path || target.endpoint_id || "unknown"}`) | ||
| .filter(Boolean); | ||
| if (missingAnchors.length > 0) { | ||
| limitations.push(`Missing safe route anchors for: ${missingAnchors.slice(0, 8).join(", ")}.`); | ||
| } | ||
| } | ||
| return { | ||
| id, | ||
| stack: metadata.stack, | ||
| ownership: metadata.ownership, | ||
| confidence, | ||
| actions: actionList.map((action) => ({ | ||
| ...action, | ||
| implementer_id: id, | ||
| implementer_confidence: confidence, | ||
| implementer_app_state: normalizedAppState, | ||
| experience_target_ids: recordArray(uiTargets).map((target) => target.id).filter(Boolean) | ||
| })), | ||
| targets: targetList, | ||
| experience_targets: recordArray(uiTargets), | ||
| app_state: normalizedAppState, | ||
| project_commands: projectCommands || null, | ||
| limitations, | ||
| fallback_instruction: hasExecutableAction | ||
| ? null | ||
| : "Use operation_targets to inspect the maintained app entrypoints, then implement the current endpoint operations manually and run the proof command." | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} implementer | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function implementerSummary(implementer) { | ||
| if (!implementer) return null; | ||
| return { | ||
| id: implementer.id || null, | ||
| stack: implementer.stack || null, | ||
| ownership: implementer.ownership || null, | ||
| app_state: implementer.app_state || null, | ||
| confidence: implementer.confidence || null, | ||
| actions: recordArray(implementer.actions).map(actionSummary), | ||
| targets: recordArray(implementer.targets).map(targetSummary), | ||
| project_commands: implementer.project_commands || null, | ||
| limitations: Array.isArray(implementer.limitations) ? implementer.limitations : [], | ||
| fallback_instruction: implementer.fallback_instruction || null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} checkpoint | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function agentCheckpointSummary(checkpoint) { | ||
| if (!checkpoint) return null; | ||
| const next = String(checkpoint.next_command || "topogram work next ./topo --task <task-id> --mode implementation --json") | ||
| .replace(/\s+--include-file\s+\S+/g, ""); | ||
| return { | ||
| ...checkpoint, | ||
| next_command: next | ||
| }; | ||
| } |
| // @ts-check | ||
| import { requiredOperationModelWork } from "./operation-model-work.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function slug(value) { | ||
| return String(value || "feature") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "_") | ||
| .replace(/^_+|_+$/g, "") | ||
| .replace(/^([0-9])/, "item_$1") || "feature"; | ||
| } | ||
| /** | ||
| * @param {string[]} terms | ||
| * @returns {string} | ||
| */ | ||
| function featureSlug(terms) { | ||
| const important = terms | ||
| .map(slug) | ||
| .filter((term) => term && !["implement", "implementation", "feature"].includes(term)) | ||
| .slice(0, 3); | ||
| return important.length > 0 ? important.join("_") : "feature"; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function candidateGuidance(prep) { | ||
| return prep?.workflow_step?.task_link_guidance | ||
| || prep?.agent_payload?.active_bucket_packet?.payload?.candidate_contracts_not_linked_to_task | ||
| || null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function featureCoverage(prep) { | ||
| return prep?.implementation_packet?.feature_contract_coverage | ||
| || prep?.agent_payload?.implementation_packet?.feature_contract_coverage | ||
| || prep?.agent_payload?.active_bucket_packet?.payload?.feature_contract_coverage | ||
| || {}; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function proposedModelWork(prep) { | ||
| if (!prep || !["modeling_needed", "task_unlinked"].includes(String(prep.state))) return []; | ||
| const guidance = candidateGuidance(prep); | ||
| const suggestedFeature = guidance?.suggested_feature || guidance?.suggestedFeature || null; | ||
| if (suggestedFeature) return [{ action: "link_existing_feature", kind: "feature", id: suggestedFeature, why: "Existing feature record matches the current task; link it from task.feature instead of inferring scope from prose." }]; | ||
| const suggestedAffects = Array.isArray(guidance?.suggested_affects) ? guidance.suggested_affects : []; | ||
| const suggestedVerificationRefs = Array.isArray(guidance?.suggested_verification_refs) ? guidance.suggested_verification_refs : []; | ||
| if (suggestedAffects.length > 0 || suggestedVerificationRefs.length > 0) { | ||
| return [ | ||
| ...suggestedAffects.map((id) => ({ action: "link_existing_record", kind: "capability", id, why: "Existing model record matches the current task; link it from task.affects instead of creating a new record." })), | ||
| ...suggestedVerificationRefs.map((id) => ({ action: "link_existing_record", kind: "verification", id, why: "Existing verification matches the current task; link it from task.verification_refs instead of creating a new record." })) | ||
| ]; | ||
| } | ||
| const operationWork = requiredOperationModelWork(prep); | ||
| if (operationWork.length > 0) return operationWork; | ||
| const coverage = featureCoverage(prep); | ||
| if (coverage.coverage_mode === "required_operations" && recordArray(coverage.missing_required_operations).length === 0) return []; | ||
| const missingTerms = Array.isArray(coverage.missing_terms) ? coverage.missing_terms : []; | ||
| const base = featureSlug(missingTerms.length > 0 ? missingTerms : Object.values(prep.selector || {})); | ||
| const title = base.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); | ||
| return [ | ||
| { kind: "feature", id: `feature_${base}`, why: "Create a named feature scope first so follow-up tasks and work packets use explicit model scope instead of prose inference.", snippet: `feature feature_${base} {\n name "${title}"\n description "Current-feature scope for this implementation task."\n intent "Make ${title.toLowerCase()} behavior available in the app."\n entities [entity_${base}]\n capabilities [cap_list_${base}]\n endpoints [endpoint_list_${base}]\n seed_data [seed_${base}]\n verification_refs [verification_${base}_smoke]\n status active\n}` }, | ||
| { kind: "entity", id: `entity_${base}`, why: "Add a data object only if this feature introduces a durable domain object.", snippet: `entity entity_${base} {\n name "${title}"\n description "Current-feature data needed by the task."\n fields {\n id string\n status string\n }\n status active\n}` }, | ||
| { kind: "capability", id: `cap_list_${base}`, why: "Add or adjust a capability for the user-visible operation the app must perform.", snippet: `capability cap_list_${base} {\n name "List ${title}"\n description "Return current-feature ${title.toLowerCase()} records for the implementation task."\n reads [entity_${base}]\n status active\n}` }, | ||
| { kind: "endpoint", id: `endpoint_list_${base}`, why: "Expose the capability through an operation-named API endpoint.", snippet: `endpoint endpoint_list_${base} {\n name "List ${title} Endpoint"\n description "Expose current-feature ${title.toLowerCase()} records."\n method GET\n path "/api/${base.replace(/_/g, "-")}"\n capability cap_list_${base}\n success 200\n auth user\n request none\n response_result collection\n response_entity entity_${base}\n response_container json_array\n status active\n}` }, | ||
| { kind: "seed_data", id: `seed_${base}`, why: "Add deterministic records when the endpoint should be demonstrable from local sample data.", snippet: `seed_data seed_${base} {\n name "${title} Seeds"\n description "Demo records for the current feature."\n entity entity_${base}\n purpose demo_fixture\n record {\n id ${base}_sample\n field id "${base}_sample"\n field status "open"\n }\n status active\n}` }, | ||
| { kind: "verification", id: `verification_${base}_smoke`, why: "Name the proof that demonstrates the current feature behavior.", snippet: `verification verification_${base}_smoke {\n name "${title} Smoke Verification"\n description "Verify the current feature operation responds."\n validates [cap_list_${base}]\n method smoke\n scenarios [${base}_responds]\n status active\n}` } | ||
| ]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} proposed | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function firstProposedFeature(proposed) { | ||
| return proposed.find((record) => record?.kind === "feature" && record?.id && !record.action && record.snippet) || null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} proposed | ||
| * @returns {string|null} | ||
| */ | ||
| export function proposedFeatureId(proposed) { | ||
| return proposed.find((record) => record?.kind === "feature" && record?.id)?.id || null; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} featureId | ||
| * @returns {string|null} | ||
| */ | ||
| export function proposedFeatureFile(featureId) { | ||
| if (!featureId) return null; | ||
| return `topo/features/${slug(String(featureId).replace(/^feature_/, ""))}.tg`; | ||
| } | ||
| /** | ||
| * @param {string|null} file | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function proposedSourceRef(file) { | ||
| return file ? { status: "proposed", file } : { status: "unknown" }; | ||
| } |
| // @ts-check | ||
| import { textTokenStats } from "../../../token-estimate.js"; | ||
| import { buildCheckCommandPayload } from "../check.js"; | ||
| import { buildSlice, normalizeTopogramPath } from "../query/workspace.js"; | ||
| import { buildImplementationPrepQuery } from "../query/runner/implementation-prep.js"; | ||
| import { buildModelRepairQuery } from "../query/runner/model-repair.js"; | ||
| import { workNextActions } from "./actions.js"; | ||
| import { agentCheckpointSummary, buildImplementer, experienceTargets, implementerSummary, operationTargets } from "./implementer.js"; | ||
| import { | ||
| candidateGuidance, | ||
| featureCoverage, | ||
| firstProposedFeature, | ||
| proposedFeatureFile, | ||
| proposedFeatureId, | ||
| proposedModelWork, | ||
| proposedSourceRef | ||
| } from "./model-proposals.js"; | ||
| import { effectiveEndpointContracts, requiredOperationsAsContracts } from "./operation-contracts.js"; | ||
| import { keyVariants, patchReadyForTarget, seedMatchKeys } from "./patch-ready.js"; | ||
| import { | ||
| actionSummary, | ||
| contractSummary, | ||
| coverageSummary, | ||
| preferredActionSummary, | ||
| proposedModelSummary, | ||
| targetSummary, | ||
| taskRecordEditSummary | ||
| } from "./summaries.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** @type {Record<string, string>} */ | ||
| const STATE_MAP = { | ||
| model_invalid: "model_invalid", | ||
| modeling_needed: "model_missing", | ||
| task_unlinked: "model_link_needed", | ||
| scaffold_needed: "scaffold_needed", | ||
| implementation_ready: "code_edit_ready", | ||
| verification_needed: "verify_ready", | ||
| done: "done" | ||
| }; | ||
| /** | ||
| * @param {AnyRecord} value | ||
| * @returns {number} | ||
| */ | ||
| function estimatedTokens(value) { | ||
| return textTokenStats(JSON.stringify(value || {})).estimated_tokens; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} selectors | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function selectorSummary(selectors) { | ||
| const entries = [ | ||
| ["task", selectors.taskId], | ||
| ["bug", selectors.bugId], | ||
| ["capability", selectors.capabilityId], | ||
| ["entity", selectors.entityId], | ||
| ["screen", selectors.screenId], | ||
| ["widget", selectors.componentId || selectors.widgetId], | ||
| ["journey", selectors.journeyId], | ||
| ["domain", selectors.domainId], | ||
| ["feature", selectors.featureId], | ||
| ["requirement", selectors.requirementId], | ||
| ["acceptance", selectors.acceptanceId], | ||
| ["verification", selectors.verificationId], | ||
| ["mode", selectors.modeId] | ||
| ]; | ||
| /** @type {AnyRecord} */ | ||
| const summary = {}; | ||
| for (const [key, value] of entries) { | ||
| if (value) summary[key] = value; | ||
| } | ||
| return summary; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} record | ||
| * @param {string|null} featureFile | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {string|null} | ||
| */ | ||
| function modelRecordTargetFile(record, featureFile, prep) { | ||
| if (record?.file) return String(record.file); | ||
| if (record.action === "link_existing_record" || record.action === "link_existing_feature") return taskSourceFile(prep); | ||
| if (record.action === "update_existing_feature") return null; | ||
| return featureFile || "topo/features/current-feature.tg"; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function generatedTaskRecordEdit(prep) { | ||
| if (String(prep?.state || "") !== "modeling_needed") return null; | ||
| const coverage = featureCoverage(prep); | ||
| if (coverage?.feature_id) return null; | ||
| const proposed = proposedModelWork(prep); | ||
| const feature = firstProposedFeature(proposed); | ||
| if (!feature?.id) return null; | ||
| const targetFile = proposedFeatureFile(String(feature.id)); | ||
| return { | ||
| task_id: prep?.selector?.task || null, | ||
| action: "link_current_task_to_new_feature", | ||
| snippet: ` feature ${feature.id}`, | ||
| note: `Add the proposed feature records in ${targetFile || "the proposed model file"} and add this feature field to the current task in the same Topogram edit, then rerun work next. Do not put feature ids in affects; affects can reference concrete capabilities, endpoints, entities, screens, sections, and verifications after they exist.`, | ||
| generated_from_proposed_model_work: true, | ||
| feature_id: feature.id, | ||
| model_file: targetFile | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function taskRecordEdit(prep) { | ||
| return candidateGuidance(prep)?.suggested_task_record_edit | ||
| || generatedTaskRecordEdit(prep) | ||
| || null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function taskSourceRef(prep) { | ||
| const ref = prep?.focus_source_ref || prep?.agent_payload?.focus_source_ref || null; | ||
| if (ref?.status === "known" && ref.file) { | ||
| return { | ||
| status: "known", | ||
| file: String(ref.file), | ||
| line: ref.line || null | ||
| }; | ||
| } | ||
| return { status: "unknown" }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {string|null} | ||
| */ | ||
| function taskSourceFile(prep) { | ||
| const ref = taskSourceRef(prep); | ||
| return ref.status === "known" ? ref.file : null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {string} endpointId | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function endpointAnchor(fileContext, endpointId) { | ||
| const marker = `topogram:endpoint ${endpointId}`; | ||
| for (const file of fileContext || []) { | ||
| if (!file?.ok) continue; | ||
| const line = recordArray(file.relevant_lines).find((entry) => String(entry.text || "").includes(marker)); | ||
| if (line) { | ||
| return { | ||
| file: file.path, | ||
| marker, | ||
| line: line.line, | ||
| anchor: `${file.path}:${line.line}`, | ||
| status: "marker_found" | ||
| }; | ||
| } | ||
| if (Array.isArray(file.markers) && file.markers.map(String).includes(marker)) { | ||
| return { | ||
| file: file.path, | ||
| marker, | ||
| line: null, | ||
| anchor: marker, | ||
| status: "marker_found" | ||
| }; | ||
| } | ||
| } | ||
| const server = (fileContext || []).find((file) => file?.path === "server.mjs" && file.ok); | ||
| const createServerLine = recordArray(server?.relevant_lines).find((entry) => /createServer/.test(String(entry.text || ""))); | ||
| const serverPath = server?.path || "server.mjs"; | ||
| return { | ||
| file: serverPath, | ||
| marker, | ||
| line: createServerLine?.line || null, | ||
| anchor: createServerLine ? `${serverPath}:${createServerLine.line}` : "server.mjs route handler", | ||
| status: "marker_missing" | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {string|null|undefined} method | ||
| * @param {string|null|undefined} apiPath | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function routeLine(fileContext, method, apiPath) { | ||
| const wantedMethod = String(method || "").toUpperCase(); | ||
| const wantedPath = String(apiPath || ""); | ||
| for (const file of fileContext || []) { | ||
| for (const route of recordArray(file?.route_lines)) { | ||
| if (String(route.method || "").toUpperCase() !== wantedMethod) continue; | ||
| if (String(route.path || "") !== wantedPath) continue; | ||
| return { | ||
| file: file.path || null, | ||
| line: route.line || null, | ||
| text: route.text || null, | ||
| status: "route_found" | ||
| }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {string|null|undefined} method | ||
| * @param {string|null|undefined} apiPath | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function routeBlock(fileContext, method, apiPath) { | ||
| const wantedMethod = String(method || "").toUpperCase(); | ||
| const wantedPath = String(apiPath || ""); | ||
| for (const file of fileContext || []) { | ||
| for (const route of recordArray(file?.route_blocks)) { | ||
| if (String(route.method || "").toUpperCase() !== wantedMethod) continue; | ||
| if (String(route.path || "") !== wantedPath) continue; | ||
| return { | ||
| file: file.path || null, | ||
| line: route.line || null, | ||
| text: route.text || null, | ||
| line_count: route.line_count || null, | ||
| status: "route_block_found" | ||
| }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function routeIndex(fileContext) { | ||
| return (fileContext || []).flatMap((file) => | ||
| recordArray(file?.route_lines).map((route) => ({ | ||
| file: file.path || null, | ||
| line: route.line || null, | ||
| method: route.method || null, | ||
| path: route.path || null | ||
| })) | ||
| ).slice(0, 60); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {AnyRecord|null|undefined} contract | ||
| * @param {AnyRecord[]} seeds | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function fixtureSeedSource(fileContext, contract, seeds) { | ||
| const keys = seedMatchKeys(contract, seeds); | ||
| /** @type {AnyRecord[]} */ | ||
| const matches = []; | ||
| for (const file of fileContext || []) { | ||
| if (!/seed-fixture\.json$/.test(String(file?.path || ""))) continue; | ||
| for (const sample of recordArray(file.seed_samples)) { | ||
| const sampleKey = String(sample.key || ""); | ||
| const samplePath = String(sample.path || ""); | ||
| const variants = [ | ||
| ...keyVariants(sampleKey), | ||
| ...keyVariants(samplePath.split(".").at(-1) || ""), | ||
| ...keyVariants(samplePath) | ||
| ]; | ||
| if (!variants.some((variant) => keys.has(variant) || keys.has(variant.replace(/^wave_[0-9]+_/, "")))) continue; | ||
| matches.push({ | ||
| file: file.path, | ||
| path: sample.path || null, | ||
| key: sample.key || null, | ||
| record_count: sample.record_count || 0, | ||
| sample_records: recordArray(sample.sample_records).slice(0, 2) | ||
| }); | ||
| if (matches.length >= 4) return matches; | ||
| } | ||
| } | ||
| return matches; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function projectCommands(fileContext) { | ||
| const pkg = (fileContext || []).find((file) => file?.path === "package.json" && file?.ok); | ||
| return pkg?.package_scripts || null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {boolean} | ||
| */ | ||
| function minimalNodeHttpEntrypoint(fileContext) { | ||
| const server = (fileContext || []).find((file) => file?.path === "server.mjs" && file?.ok); | ||
| if (!server) return false; | ||
| const hasServer = recordArray(server.relevant_lines).some((line) => | ||
| /createServer|server\.listen/.test(String(line.text || "")) | ||
| ); | ||
| const hasRecognizedRoutes = recordArray(server.route_lines).length > 0; | ||
| const hasFallback = recordArray(server.fallback_lines).length > 0; | ||
| return hasServer | ||
| && !hasRecognizedRoutes | ||
| && !hasFallback | ||
| && Number(server.bytes || 0) > 0 | ||
| && Number(server.bytes || 0) <= 1500; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @param {string|null|undefined} appState | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function codeEditTargets(prep, appState = null) { | ||
| const payload = prep?.agent_payload || {}; | ||
| const contracts = effectiveEndpointContracts(prep); | ||
| const seedSummaries = recordArray(payload.seed_summaries); | ||
| const fileContext = recordArray(payload.file_context || prep?.file_context); | ||
| const normalizedAppState = String(appState || "unknown"); | ||
| const allowFullFileReplacement = normalizedAppState === "minimal_placeholder" | ||
| && minimalNodeHttpEntrypoint(fileContext); | ||
| return contracts.map((contract) => { | ||
| const anchor = endpointAnchor(fileContext, String(contract.id || "")); | ||
| const seeds = seedSummaries.filter((seed) => seed.endpoint_id === contract.id); | ||
| const existingRoute = routeLine(fileContext, contract.method, contract.path); | ||
| const existingRouteBlock = routeBlock(fileContext, contract.method, contract.path); | ||
| const fixtureSeeds = fixtureSeedSource(fileContext, contract, seeds); | ||
| const patchReady = patchReadyForTarget({ contract, fileContext, seeds, fixtureSeeds, anchor, existingRoute, existingRouteBlock }); | ||
| return { | ||
| kind: "endpoint_handler", | ||
| file: anchor.file, | ||
| endpoint_id: contract.id || null, | ||
| capability_id: contract.capability_id || null, | ||
| method: contract.method || null, | ||
| api_path: contract.path || null, | ||
| expected_status: contract.success_status || 200, | ||
| expected_response: contract.response || null, | ||
| seed_source: seeds, | ||
| fixture_seed_source: fixtureSeeds, | ||
| existing_marker: anchor.status === "marker_found" ? anchor.marker : null, | ||
| existing_route: existingRoute, | ||
| existing_route_block: existingRouteBlock, | ||
| app_state: normalizedAppState, | ||
| allow_full_file_replacement: allowFullFileReplacement, | ||
| insertion_anchor: anchor.anchor, | ||
| anchor_status: anchor.status, | ||
| nearby_routes: routeIndex(fileContext), | ||
| available_commands: projectCommands(fileContext), | ||
| patch_intent: String(contract.method || "GET").toUpperCase() === "GET" && seeds.length > 0 | ||
| ? "Apply patch_ready if present. It returns deterministic seed-backed read data for this endpoint from seed_source or fixture_seed_source, preserving the marker region. Do not read seed-fixture.json unless fixture_seed_source is empty." | ||
| : "Apply patch_ready if present. It implements the endpoint behavior expected by the contract while preserving scaffold custom regions.", | ||
| patch_ready: patchReady, | ||
| proof_command: "npm run verify", | ||
| follow_up: "After applying all current code_edit_targets, run_public_check before ending the wave." | ||
| }; | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @param {string|null|undefined} appState | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function editTargets(prep, appState = null) { | ||
| const state = String(prep?.state || ""); | ||
| if (state === "model_invalid") { | ||
| return recordArray(prep?.diagnostics || prep?.agent_payload?.topogram_check?.diagnostics).slice(0, 8).map((diagnostic) => ({ | ||
| kind: "model_repair", | ||
| file: diagnostic.file || null, | ||
| line: diagnostic.line || null, | ||
| diagnostic_id: diagnostic.id || null, | ||
| category: diagnostic.category || "unknown", | ||
| patch_intent: diagnostic.suggested_action || diagnostic.message || "Repair this invalid Topogram source." | ||
| })); | ||
| } | ||
| if (state === "task_unlinked") { | ||
| const patch = taskRecordEdit(prep); | ||
| return patch ? [{ | ||
| kind: "task_record_edit", | ||
| file: taskSourceFile(prep), | ||
| source_ref: taskSourceRef(prep), | ||
| task_id: patch.task_id || prep?.selector?.task || null, | ||
| patch_intent: patch.action === "link_current_task_to_feature" | ||
| ? "Link the task to the existing feature record so work next can use structured feature scope." | ||
| : "Link the task to already-modeled capabilities and verification records.", | ||
| snippet: patch.snippet || null, | ||
| note: patch.note || null | ||
| }] : []; | ||
| } | ||
| if (state === "modeling_needed") { | ||
| /** @type {AnyRecord[]} */ | ||
| const proposed = proposedModelWork(prep); | ||
| const modelFile = proposedFeatureFile(proposedFeatureId(proposed)); | ||
| /** @type {AnyRecord[]} */ | ||
| const targets = proposed.map((record) => { | ||
| const file = modelRecordTargetFile(record, modelFile, prep); | ||
| const isTaskLink = record.action === "link_existing_record" || record.action === "link_existing_feature"; | ||
| return { | ||
| kind: isTaskLink ? "task_link" : "model_record", | ||
| file, | ||
| source_ref: isTaskLink ? taskSourceRef(prep) : proposedSourceRef(file), | ||
| record_kind: record.kind, | ||
| record_id: record.id, | ||
| action: record.action || "create_model_record", | ||
| patch_intent: record.why, | ||
| snippet: record.snippet || null | ||
| }; | ||
| }); | ||
| const patch = taskRecordEdit(prep); | ||
| if (patch) { | ||
| targets.push({ | ||
| kind: "task_record_edit", | ||
| file: taskSourceFile(prep), | ||
| source_ref: taskSourceRef(prep), | ||
| task_id: patch.task_id || prep?.selector?.task || null, | ||
| action: patch.action || "link_current_task_to_feature", | ||
| record_kind: "feature", | ||
| record_id: patch.feature_id || null, | ||
| patch_intent: "Add or update the task feature field for the created feature; do not add feature ids to affects.", | ||
| snippet: patch.snippet || null, | ||
| note: patch.note || null | ||
| }); | ||
| } | ||
| return targets; | ||
| } | ||
| if (state === "scaffold_needed") { | ||
| return [{ | ||
| kind: "scaffold", | ||
| file: "server.mjs", | ||
| patch_intent: "Run the scaffold command so endpoint markers and preserved custom regions match the current model.", | ||
| command: prep?.scaffold_status?.command || prep?.agent_payload?.scaffold_status?.command || "topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --write --out-dir .", | ||
| patch_plan: prep?.scaffold_status?.patch_plan || prep?.agent_payload?.scaffold_status?.patch_plan || null | ||
| }]; | ||
| } | ||
| return codeEditTargets(prep, appState); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} workflow | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function workFlowStep(workflow) { | ||
| /** | ||
| * @param {unknown} action | ||
| * @returns {string} | ||
| */ | ||
| const mapAction = (action) => String(action || "").replace(/run_topogram:implementation_prep/g, "run_topogram:work_next"); | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {string} | ||
| */ | ||
| const normalizeInstruction = (value) => String(value || "") | ||
| .replace(/implementation-prep/g, "work next") | ||
| .replace(/code_edit_targets/g, "operation_targets and implementer targets") | ||
| .replace(/Apply each target's patch_ready replace_file_text args when present, then run_public_check\./g, "Use implementer actions when present, then run the public proof.") | ||
| .replace(/patch_ready replace_file_text args/g, "implementer action args") | ||
| .replace(/patch_ready/g, "implementer patch") | ||
| .replace(/server\.mjs, seed-fixture\.json, or package\.json/g, "broad app files or fixture files") | ||
| .replace(/a implementer patch anchor/g, "an implementer anchor") | ||
| .replace(/a patch_ready anchor/g, "an implementer anchor"); | ||
| return { | ||
| instruction: normalizeInstruction(workflow?.instruction || "Follow the active work packet."), | ||
| success_condition: normalizeInstruction(workflow?.success_condition || ""), | ||
| allowed_actions: (workflow?.allowed_actions || []).map(mapAction), | ||
| blocked_actions: (workflow?.blocked_actions || []).map(mapAction), | ||
| exact_next_command: String(workflow?.exact_next_command || "topogram work next ./topo --task <task-id> --mode implementation --json") | ||
| .replace(/topogram query implementation-prep/g, "topogram work next") | ||
| .replace(/implementation-prep/g, "work next"), | ||
| rerun_command: String(workflow?.rerun_command || "topogram work next ./topo --task <task-id> --mode implementation --json") | ||
| .replace(/topogram query implementation-prep/g, "topogram work next") | ||
| .replace(/implementation-prep/g, "work next") | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @param {AnyRecord} selectors | ||
| * @param {string} mode | ||
| * @param {{ implementerId?: string|null, appState?: string|null }} [options] | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildWorkNextFromImplementationPrep(prep, selectors, mode = "implementation", options = {}) { | ||
| const safePrep = prep || {}; | ||
| const implementerId = options.implementerId || null; | ||
| const appState = options.appState || "unknown"; | ||
| let state = STATE_MAP[String(safePrep.state || "")] || "model_missing"; | ||
| const actualEndpointContracts = recordArray(safePrep.agent_payload?.endpoint_contracts); | ||
| const requiredOperations = recordArray(safePrep.agent_payload?.required_operations || safePrep.implementation_packet?.feature_contract_coverage?.required_operations); | ||
| const endpointContracts = state === "code_edit_ready" && actualEndpointContracts.length === 0 | ||
| ? requiredOperationsAsContracts(requiredOperations) | ||
| : actualEndpointContracts; | ||
| const workflow = workFlowStep(safePrep.workflow_step || {}); | ||
| const targets = editTargets(safePrep, appState); | ||
| const coverage = featureCoverage(safePrep); | ||
| const contracts = { | ||
| endpoint_contracts: endpointContracts, | ||
| required_operations: requiredOperations, | ||
| seed_summaries: recordArray(safePrep.agent_payload?.seed_summaries), | ||
| response_shapes: endpointContracts.map((contract) => ({ | ||
| endpoint_id: contract.id || null, | ||
| response: contract.response || null, | ||
| success_status: contract.success_status || null | ||
| })), | ||
| verification_ids: [...new Set(endpointContracts.flatMap((contract) => Array.isArray(contract.verification_ids) ? contract.verification_ids : []))] | ||
| }; | ||
| const fileContext = recordArray(safePrep.agent_payload?.file_context || safePrep.file_context); | ||
| const includesMaintainedEntrypoint = fileContext.some((file) => file?.path === "server.mjs" && file?.ok); | ||
| if (state === "code_edit_ready" && endpointContracts.length === 0 && includesMaintainedEntrypoint) { | ||
| state = "model_missing"; | ||
| workflow.instruction = "Model at least one current operation or expose required API operations before editing app code."; | ||
| workflow.success_condition = "The next work packet includes operation_targets for the current feature."; | ||
| workflow.allowed_actions = ["edit:topo/**", "run_topogram:work_next", "run_topogram:check"]; | ||
| workflow.blocked_actions = ["edit:app_code", "run_public_check"]; | ||
| } | ||
| const proposed = proposedModelWork(safePrep); | ||
| const patch = taskRecordEdit(safePrep); | ||
| if (state === "model_missing" && patch?.action === "link_current_task_to_new_feature") { | ||
| workflow.instruction = "Add the proposed current-feature model records, link the task to the new feature in the same Topogram edit, then rerun work next."; | ||
| workflow.success_condition = "The current task has a feature field pointing at the new feature, affects does not contain feature ids, and work next advances past model_link_needed."; | ||
| workflow.allowed_actions = ["edit:topo/**", "run_topogram:work_next", "run_topogram:check"]; | ||
| workflow.blocked_actions = ["edit:app_code", "run_public_check", "run_topogram:scaffold"]; | ||
| } | ||
| const scaffold = safePrep.agent_payload?.scaffold_status || recordArray(safePrep.buckets).find((bucket) => bucket.id === "scaffold")?.payload || null; | ||
| const codeTargets = state === "code_edit_ready" ? codeEditTargets(safePrep, appState) : []; | ||
| const operations = operationTargets(contracts); | ||
| const experiences = experienceTargets(fileContext); | ||
| const actions = workNextActions({ state, targets, codeTargets, patch, scaffold, experienceTargets: experiences, fileContext }); | ||
| const implementer = buildImplementer({ | ||
| mode, | ||
| state, | ||
| codeTargets, | ||
| actions, | ||
| fileContext, | ||
| projectCommands: projectCommands(fileContext), | ||
| implementerId, | ||
| appState, | ||
| experienceTargets: experiences | ||
| }); | ||
| const packetActions = state === "code_edit_ready" && implementer ? recordArray(implementer.actions) : actions; | ||
| const preferredAction = packetActions.find((action) => action.preferred) || null; | ||
| const checkpoint = { | ||
| type: "topogram_work_checkpoint", | ||
| version: 1, | ||
| task_id: selectors.taskId || safePrep.selector?.task || null, | ||
| mode, | ||
| state, | ||
| source_state: safePrep.state || null, | ||
| do_now: workflow.instruction, | ||
| model_valid: safePrep.topogram_check?.ok === true, | ||
| active_bucket: safePrep.active_bucket || null, | ||
| endpoint_ids: contracts.endpoint_contracts.map((contract) => contract.id).filter(Boolean), | ||
| experience_target_ids: experiences.map((target) => target.id).filter(Boolean), | ||
| scaffold_status: scaffold?.status || null, | ||
| implementer_id: implementer?.id || null, | ||
| implementer_confidence: implementer?.confidence || null, | ||
| implementer_app_state: implementer?.app_state || appState, | ||
| next_command: workflow.rerun_command | ||
| }; | ||
| const drillDown = []; | ||
| if (state === "model_invalid") drillDown.push("topogram query repair-model ./topo --json"); | ||
| if (state === "model_missing" && proposed.length === 0) { | ||
| drillDown.push("topogram query modeling-guide ./topo --mode greenfield-app --format markdown"); | ||
| } | ||
| for (const query of recordArray(safePrep.agent_payload?.next_queries || safePrep.next_commands)) { | ||
| const rewritten = String(query) | ||
| .replace(/topogram query implementation-prep/g, "topogram work next") | ||
| .replace(/implementation-prep/g, "work next"); | ||
| if (/modeling-guide/.test(rewritten) && proposed.length > 0) continue; | ||
| if (!drillDown.includes(rewritten)) drillDown.push(rewritten); | ||
| } | ||
| const agentPacket = { | ||
| type: "topogram_work_next_agent_packet", | ||
| version: 1, | ||
| state, | ||
| do_now: workflow.instruction, | ||
| success_condition: workflow.success_condition, | ||
| allowed_actions: workflow.allowed_actions, | ||
| blocked_actions: workflow.blocked_actions, | ||
| operation_targets: operations, | ||
| experience_targets: experiences, | ||
| implementer: implementerSummary(implementer), | ||
| edit_targets: state === "code_edit_ready" ? [] : targets.map(targetSummary), | ||
| code_edit_targets: [], | ||
| actions: state === "code_edit_ready" ? [] : packetActions.map(actionSummary), | ||
| preferred_action: preferredActionSummary(preferredAction), | ||
| proposed_model_work: proposed.map(proposedModelSummary), | ||
| task_record_edit: taskRecordEditSummary(patch), | ||
| contracts: contractSummary(contracts), | ||
| feature_contract_coverage: coverageSummary(coverage), | ||
| source_ref: taskSourceRef(safePrep), | ||
| modeling_guidance: state === "model_missing" ? { | ||
| instruction: "Add only the Topogram records needed for the current task, then rerun work next.", | ||
| order: [ | ||
| "entity_or_shape_if_needed", | ||
| "capability", | ||
| "endpoint", | ||
| "seed_data", | ||
| "verification", | ||
| "task_link" | ||
| ], | ||
| use_proposed_model_work: proposed.length > 0 | ||
| } : null, | ||
| checkpoint: agentCheckpointSummary(checkpoint), | ||
| drill_down: drillDown, | ||
| omitted_details: [ | ||
| "full_action_tool_calls", | ||
| "full_patch_ready_replacements", | ||
| "full_model_snippets", | ||
| "full_edit_target_seed_samples" | ||
| ], | ||
| estimated_tokens: 0 | ||
| }; | ||
| agentPacket.estimated_tokens = estimatedTokens(agentPacket); | ||
| return { | ||
| type: "topogram_work_next", | ||
| version: 1, | ||
| mode, | ||
| state, | ||
| source_query: "implementation-prep", | ||
| source_state: safePrep.state || null, | ||
| do_now: workflow.instruction, | ||
| success_condition: workflow.success_condition, | ||
| allowed_actions: workflow.allowed_actions, | ||
| blocked_actions: workflow.blocked_actions, | ||
| edit_targets: targets, | ||
| code_edit_targets: codeTargets, | ||
| operation_targets: operations, | ||
| experience_targets: experiences, | ||
| implementer, | ||
| actions: packetActions, | ||
| preferred_action: preferredAction, | ||
| proposed_model_work: proposed, | ||
| task_record_edit: patch, | ||
| contracts, | ||
| feature_contract_coverage: coverage, | ||
| source_ref: taskSourceRef(safePrep), | ||
| modeling_guidance: agentPacket.modeling_guidance, | ||
| scaffold_status: scaffold, | ||
| topogram_check: safePrep.topogram_check || null, | ||
| checkpoint, | ||
| drill_down: drillDown, | ||
| agent_packet: agentPacket, | ||
| omitted_sections: [ | ||
| "full_context_slice", | ||
| "inactive_bucket_payloads", | ||
| "full_included_file_content", | ||
| "legacy_implementation_prep_query" | ||
| ], | ||
| legacy_query_summary: { | ||
| type: safePrep.type || null, | ||
| state: safePrep.state || null, | ||
| active_bucket: safePrep.active_bucket || null, | ||
| detail_level: safePrep.detail_level || null | ||
| }, | ||
| caveats: [ | ||
| "work next is read-only. It tells an agent what to edit or run next, but it does not modify Topogram source or app code.", | ||
| "The packet is operation-focused; use drill_down commands only when the active step is blocked or ambiguous." | ||
| ] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * inputPath: string, | ||
| * selectors: AnyRecord, | ||
| * modeId?: string|null, | ||
| * detailId?: string|null, | ||
| * includeFiles?: string[], | ||
| * implementerId?: string|null, | ||
| * appState?: string|null | ||
| * }} options | ||
| * @returns {Promise<AnyRecord>} | ||
| */ | ||
| export async function buildWorkNextPacket(options) { | ||
| const selectors = { | ||
| ...options.selectors, | ||
| modeId: options.modeId || options.selectors.modeId || "implementation" | ||
| }; | ||
| const check = await buildCheckCommandPayload(options.inputPath); | ||
| const result = check.payload.ok | ||
| ? buildSlice(check.ast, selectors, options.detailId || "compact") | ||
| : { ok: false, validation: { errors: check.payload.errors || [], warnings: check.payload.warnings || [] } }; | ||
| const repairReport = check.payload.ok ? null : await buildModelRepairQuery(options.inputPath); | ||
| const includeFiles = options.includeFiles && options.includeFiles.length > 0 | ||
| ? options.includeFiles | ||
| : ["server.mjs", "seed-fixture.json", "package.json"]; | ||
| const prep = buildImplementationPrepQuery({ | ||
| selectors, | ||
| sliceResult: result, | ||
| checkPayload: check.payload, | ||
| projectRoot: check.publicContext.projectRoot, | ||
| topogramRoot: normalizeTopogramPath(options.inputPath), | ||
| detailId: options.detailId || "compact", | ||
| includeFiles, | ||
| graph: check.resolved?.graph || null, | ||
| repairReport | ||
| }); | ||
| return buildWorkNextFromImplementationPrep(prep, selectors, selectors.modeId || "implementation", { | ||
| implementerId: options.implementerId || null, | ||
| appState: options.appState || null | ||
| }); | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function slug(value) { | ||
| return String(value || "feature") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "_") | ||
| .replace(/^_+|_+$/g, "") | ||
| .replace(/^([0-9])/, "item_$1") || "feature"; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function singularSlug(value) { | ||
| const raw = slug(value); | ||
| return raw.endsWith("s") && raw.length > 4 ? raw.slice(0, -1) : raw; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} operation | ||
| * @returns {string} | ||
| */ | ||
| function operationSlug(operation) { | ||
| return slug(operation.id || String(operation.path || "").replace(/^\/api\/?/, "") || "operation"); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} operation | ||
| * @returns {string} | ||
| */ | ||
| function operationVerb(operation) { | ||
| const method = String(operation.method || "").toUpperCase(); | ||
| if (method === "GET") return String(operation.response_container || operation.response?.container || "") === "json_object" ? "get" : "list"; | ||
| if (method === "POST") return "create"; | ||
| if (method === "PUT" || method === "PATCH") return "update"; | ||
| if (method === "DELETE") return "delete"; | ||
| return slug(method || "run"); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} requiredOperations | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function requiredOperationsAsContracts(requiredOperations) { | ||
| return recordArray(requiredOperations).map((operation) => { | ||
| const base = operationSlug(operation); | ||
| const verb = operationVerb(operation); | ||
| const result = String(operation.response_container || operation.response?.container || "") === "json_object" ? "item" : "collection"; | ||
| return { | ||
| id: `endpoint_${verb}_${base}`, | ||
| source: "visible_api_contract", | ||
| operation_id: operation.id || null, | ||
| capability_id: null, | ||
| method: String(operation.method || "GET").toUpperCase(), | ||
| path: operation.path || null, | ||
| success_status: operation.success_status || operation.success || 200, | ||
| auth: "user", | ||
| request: String(operation.method || "GET").toUpperCase() === "GET" ? "none" : "body", | ||
| response: { | ||
| result, | ||
| entity_id: `entity_${singularSlug(base)}`, | ||
| container: operation.response_container || operation.response?.container || (result === "item" ? "json_object" : "json_array") | ||
| }, | ||
| seed_examples: [], | ||
| verification_ids: [] | ||
| }; | ||
| }).filter((contract) => contract.path); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function effectiveEndpointContracts(prep) { | ||
| const payload = prep?.agent_payload || {}; | ||
| const contracts = recordArray(payload.endpoint_contracts); | ||
| if (contracts.length > 0) return contracts; | ||
| return requiredOperationsAsContracts(payload.required_operations || prep?.implementation_packet?.feature_contract_coverage?.required_operations || []); | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function slug(value) { | ||
| return String(value || "feature") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "_") | ||
| .replace(/^_+|_+$/g, "") | ||
| .replace(/^([0-9])/, "item_$1") || "feature"; | ||
| } | ||
| /** | ||
| * @param {string[]} terms | ||
| * @returns {string} | ||
| */ | ||
| function featureSlug(terms) { | ||
| const important = terms | ||
| .map(slug) | ||
| .filter((term) => term && !["implement", "implementation", "feature"].includes(term)) | ||
| .slice(0, 3); | ||
| return important.length > 0 ? important.join("_") : "feature"; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function singularSlug(value) { | ||
| const raw = slug(value); | ||
| return raw.endsWith("s") && raw.length > 4 ? raw.slice(0, -1) : raw; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} operation | ||
| * @returns {string} | ||
| */ | ||
| function operationSlug(operation) { | ||
| return slug(operation.id || String(operation.path || "").replace(/^\/api\/?/, "") || "operation"); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} operation | ||
| * @returns {string} | ||
| */ | ||
| function operationVerb(operation) { | ||
| const method = String(operation.method || "").toUpperCase(); | ||
| if (method === "GET") return String(operation.response_container || "") === "json_object" ? "get" : "list"; | ||
| if (method === "POST") return "create"; | ||
| if (method === "PUT" || method === "PATCH") return "update"; | ||
| if (method === "DELETE") return "delete"; | ||
| return slug(method || "run"); | ||
| } | ||
| /** | ||
| * @param {string} base | ||
| * @returns {string} | ||
| */ | ||
| function titleFromSlug(base) { | ||
| return String(base || "feature").split("_").filter(Boolean) | ||
| .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | ||
| .join(" "); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function featureCoverage(prep) { | ||
| return prep?.implementation_packet?.feature_contract_coverage | ||
| || prep?.agent_payload?.implementation_packet?.feature_contract_coverage | ||
| || prep?.agent_payload?.active_bucket_packet?.payload?.feature_contract_coverage | ||
| || {}; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function missingRequiredOperations(prep) { | ||
| return recordArray(featureCoverage(prep)?.missing_required_operations); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} prep | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function requiredOperationModelWork(prep) { | ||
| const operations = missingRequiredOperations(prep); | ||
| if (operations.length === 0) return []; | ||
| const coverage = featureCoverage(prep); | ||
| const operationBases = operations.map(operationSlug); | ||
| const featureBase = featureSlug(operationBases); | ||
| const featureId = coverage?.feature_id || `feature_${featureBase}`; | ||
| const featureTitle = titleFromSlug(featureBase); | ||
| const endpointIds = operations.map((operation) => `endpoint_${operationVerb(operation)}_${operationSlug(operation)}`); | ||
| const capabilityIds = operations.map((operation) => `cap_${operationVerb(operation)}_${operationSlug(operation)}`); | ||
| const entityIds = operations.map((operation) => `entity_${singularSlug(operationSlug(operation))}`); | ||
| const seedIds = operations.map((operation) => `seed_${operationSlug(operation)}`); | ||
| const verificationId = `verification_${featureBase}_smoke`; | ||
| /** @type {AnyRecord[]} */ | ||
| const snippets = []; | ||
| snippets.push(coverage?.feature_id | ||
| ? { | ||
| action: "update_existing_feature", | ||
| kind: "feature", | ||
| id: featureId, | ||
| why: "The current feature exists but is missing operation-level endpoint contracts; update its endpoints/capabilities instead of using an aggregate endpoint.", | ||
| snippet: `# In ${featureId}, include operation-specific records:\n entities [${entityIds.join(" ")}]\n capabilities [${capabilityIds.join(" ")}]\n endpoints [${endpointIds.join(" ")}]\n seed_data [${seedIds.join(" ")}]\n verification_refs [${verificationId}]` | ||
| } | ||
| : { | ||
| kind: "feature", | ||
| id: featureId, | ||
| why: "Create a named feature scope that lists each required API operation separately.", | ||
| snippet: `feature ${featureId} {\n name "${featureTitle}"\n description "Current-feature scope for required API operations."\n intent "Make ${featureTitle.toLowerCase()} behavior available in the app."\n entities [${entityIds.join(" ")}]\n capabilities [${capabilityIds.join(" ")}]\n endpoints [${endpointIds.join(" ")}]\n seed_data [${seedIds.join(" ")}]\n verification_refs [${verificationId}]\n status active\n}` | ||
| }); | ||
| operations.forEach((operation, index) => { | ||
| const base = operationSlug(operation); | ||
| const title = titleFromSlug(base); | ||
| const verb = operationVerb(operation); | ||
| const result = String(operation.response_container || "") === "json_object" ? "item" : "collection"; | ||
| const container = operation.response_container || (result === "item" ? "json_object" : "json_array"); | ||
| snippets.push({ | ||
| kind: "entity", | ||
| id: entityIds[index], | ||
| why: `Model the response data for ${String(operation.method || "").toUpperCase()} ${operation.path}.`, | ||
| snippet: `entity ${entityIds[index]} {\n name "${title}"\n description "Data returned by ${String(operation.method || "").toUpperCase()} ${operation.path}."\n fields {\n id string\n status string\n }\n status active\n}` | ||
| }, { | ||
| kind: "capability", | ||
| id: capabilityIds[index], | ||
| why: `Name the product operation behind ${String(operation.method || "").toUpperCase()} ${operation.path}.`, | ||
| snippet: `capability ${capabilityIds[index]} {\n name "${titleFromSlug(`${verb}_${base}`)}"\n description "Return ${title.toLowerCase()} data for ${String(operation.method || "").toUpperCase()} ${operation.path}."\n reads [${entityIds[index]}]\n status active\n}` | ||
| }, { | ||
| kind: "endpoint", | ||
| id: endpointIds[index], | ||
| why: `Expose the exact required API operation ${String(operation.method || "").toUpperCase()} ${operation.path}.`, | ||
| snippet: `endpoint ${endpointIds[index]} {\n name "${titleFromSlug(`${verb}_${base}`)} Endpoint"\n description "Expose ${String(operation.method || "").toUpperCase()} ${operation.path} for the current feature."\n method ${String(operation.method || "GET").toUpperCase()}\n path "${operation.path}"\n capability ${capabilityIds[index]}\n success ${operation.success || 200}\n auth user\n request ${String(operation.method || "").toUpperCase() === "GET" ? "none" : "body"}\n response_result ${result}\n response_entity ${entityIds[index]}\n response_container ${container}\n status active\n}` | ||
| }, { | ||
| kind: "seed_data", | ||
| id: seedIds[index], | ||
| why: `Provide deterministic sample data for ${String(operation.method || "").toUpperCase()} ${operation.path}.`, | ||
| snippet: `seed_data ${seedIds[index]} {\n name "${title} Seeds"\n description "Demo records for ${String(operation.method || "").toUpperCase()} ${operation.path}."\n entity ${entityIds[index]}\n purpose demo_fixture\n record {\n id ${base}_sample\n field id "${base}_sample"\n field status "open"\n }\n status active\n}` | ||
| }); | ||
| }); | ||
| snippets.push({ | ||
| kind: "verification", | ||
| id: verificationId, | ||
| why: "Name the smoke proof for all required operations in this feature.", | ||
| snippet: `verification ${verificationId} {\n name "${featureTitle} Smoke Verification"\n description "Verify the required current-feature API operations respond."\n validates [${capabilityIds.join(" ")}]\n method smoke\n scenarios [${operationBases.map((base) => `${base}_responds`).join(" ")}]\n status active\n}` | ||
| }); | ||
| return snippets; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| * @typedef {{ experienceTargets?: AnyRecord[], fileContext?: AnyRecord[] }} CombinedPatchOptions | ||
| */ | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function slug(value) { | ||
| return String(value || "feature") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "_") | ||
| .replace(/^_+|_+$/g, "") | ||
| .replace(/^([0-9])/, "item_$1") || "feature"; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} value | ||
| * @returns {string[]} | ||
| */ | ||
| export function keyVariants(value) { | ||
| const normalized = slug(String(value || "")); | ||
| if (!normalized) return []; | ||
| const variants = new Set([normalized]); | ||
| if (normalized.endsWith("s") && normalized.length > 4) variants.add(normalized.slice(0, -1)); | ||
| return [...variants]; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} contract | ||
| * @param {AnyRecord[]} seeds | ||
| * @returns {Set<string>} | ||
| */ | ||
| export function seedMatchKeys(contract, seeds) { | ||
| const keys = new Set(); | ||
| for (const variant of keyVariants(contract?.id)) keys.add(variant.replace(/^endpoint_(list|get|create|update|delete)_/, "")); | ||
| for (const variant of keyVariants(contract?.path)) { | ||
| keys.add(variant.replace(/^api_/, "")); | ||
| const parts = variant.replace(/^api_/, "").split("_").filter(Boolean); | ||
| if (parts.length > 0) keys.add(parts.at(-1) || ""); | ||
| if (parts.length > 1) keys.add(parts.slice(-2).join("_")); | ||
| } | ||
| for (const seed of seeds) { | ||
| for (const variant of keyVariants(seed?.seed_id)) keys.add(variant.replace(/^seed_/, "")); | ||
| for (const variant of keyVariants(seed?.entity_id)) keys.add(variant.replace(/^entity_/, "")); | ||
| } | ||
| return keys; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {string} | ||
| */ | ||
| function jsString(value) { | ||
| return JSON.stringify(String(value ?? "")); | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} pathValue | ||
| * @returns {string} | ||
| */ | ||
| function apiLeafName(pathValue) { | ||
| const parts = String(pathValue || "") | ||
| .replace(/^\/api\/?/, "") | ||
| .split("/") | ||
| .filter(Boolean); | ||
| return slug(parts.at(-1) || "item"); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} contract | ||
| * @param {AnyRecord[]} seeds | ||
| * @param {AnyRecord[]} fixtureSeeds | ||
| * @returns {string[]} | ||
| */ | ||
| function codeSeedKeys(contract, seeds, fixtureSeeds) { | ||
| const keys = new Set(seedMatchKeys(contract, seeds)); | ||
| for (const fixture of fixtureSeeds) { | ||
| for (const variant of keyVariants(fixture?.key)) keys.add(variant); | ||
| for (const variant of keyVariants(String(fixture?.path || "").split(".").at(-1) || "")) keys.add(variant); | ||
| } | ||
| keys.delete(""); | ||
| return [...keys].slice(0, 12); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} contract | ||
| * @param {AnyRecord[]} seeds | ||
| * @returns {string|null} | ||
| */ | ||
| function responseEntityId(contract, seeds) { | ||
| return String(contract?.response?.entity_id || seeds[0]?.entity_id || "").trim() || null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {string} name | ||
| * @returns {boolean} | ||
| */ | ||
| function hasFunctionDefinition(fileContext, name) { | ||
| const pattern = new RegExp(`\\bfunction\\s+${name}\\b|\\bconst\\s+${name}\\s*=`); | ||
| return (fileContext || []).some((file) => | ||
| recordArray(file?.relevant_lines).some((line) => pattern.test(String(line.text || ""))) | ||
| ); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fixtureSeeds | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function fixtureSampleRecords(fixtureSeeds) { | ||
| return fixtureSeeds.flatMap((entry) => recordArray(entry?.sample_records)).slice(0, 12); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} contract | ||
| * @param {AnyRecord[]} seeds | ||
| * @param {AnyRecord[]} fixtureSeeds | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {string} | ||
| */ | ||
| function endpointRegionBody(contract, seeds, fixtureSeeds, fileContext) { | ||
| const method = String(contract.method || "GET").toUpperCase(); | ||
| const status = Number(contract.success_status || 200); | ||
| const entityId = responseEntityId(contract, seeds); | ||
| const keys = codeSeedKeys(contract, seeds, fixtureSeeds); | ||
| if (method === "GET" && entityId) { | ||
| const samples = fixtureSampleRecords(fixtureSeeds); | ||
| const hasSeedCollection = hasFunctionDefinition(fileContext, "seedCollection"); | ||
| const hasSeedObject = hasFunctionDefinition(fileContext, "seedObject"); | ||
| const mode = String(contract?.response?.result || contract?.response?.container || ""); | ||
| if (/item|json_object/.test(mode) && !/collection|json_array/.test(mode)) { | ||
| if (!hasSeedObject && samples.length > 0) { | ||
| return ` return sendJson(res, ${status}, ${JSON.stringify(samples[0], null, 2).replace(/\n/g, "\n ")});`; | ||
| } | ||
| if (hasSeedObject) { | ||
| return ` return sendJson(res, ${status}, seedObject(${jsString(entityId)}, ${JSON.stringify(keys)}, {}));`; | ||
| } | ||
| return ` const seeded = seedSingle(${jsString(entityId)}, ${JSON.stringify(keys)}, {});\n return sendJson(res, ${status}, seeded && typeof seeded === "object" && !Array.isArray(seeded) ? seeded : {});`; | ||
| } | ||
| if (!hasSeedCollection && samples.length > 0) { | ||
| return ` return sendJson(res, ${status}, ${JSON.stringify(samples, null, 2).replace(/\n/g, "\n ")});`; | ||
| } | ||
| return ` return sendJson(res, ${status}, seedCollection(${jsString(entityId)}, ${JSON.stringify(keys)}));`; | ||
| } | ||
| if (method === "POST") { | ||
| const leaf = apiLeafName(contract.path); | ||
| const fallbackId = `${leaf}_created`; | ||
| const fallbackStatus = leaf === "no_shows" || leaf === "no_show" ? "recorded" : "saved"; | ||
| return ` const body = await readJsonBody(req);\n return sendJson(res, ${status}, {\n id: body.id || ${jsString(fallbackId)},\n status: body.status || ${jsString(fallbackStatus)},\n ...body\n });`; | ||
| } | ||
| return ` return sendJson(res, ${status}, { ok: true, endpoint: ${jsString(contract.id || "")} });`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} contract | ||
| * @param {string} body | ||
| * @returns {string} | ||
| */ | ||
| function endpointHandlerBlock(contract, body) { | ||
| const method = String(contract.method || "GET").toUpperCase(); | ||
| const apiPath = String(contract.path || "/"); | ||
| const id = String(contract.id || "endpoint"); | ||
| return ` if (req.method === ${jsString(method)} && url.pathname === ${jsString(apiPath)}) {\n // topogram:endpoint ${id} start\n${body}\n // topogram:endpoint ${id} end\n }`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {string} marker | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function markerBlock(fileContext, marker) { | ||
| for (const file of fileContext || []) { | ||
| const block = recordArray(file?.marker_blocks).find((entry) => String(entry.marker || "") === marker); | ||
| if (block?.text) return { file: file.path || null, line: block.start_line || null, text: block.text }; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function fallbackLine(fileContext) { | ||
| for (const file of fileContext || []) { | ||
| const line = recordArray(file?.fallback_lines).find((entry) => | ||
| /\bnotFound\(res\)\s*;?/.test(String(entry.text || "")) | ||
| || /\b(?:return\s+)?sendJson\(res,\s*404\b/.test(String(entry.text || "")) | ||
| ); | ||
| if (line?.text) return { file: file.path || null, line: line.line || null, text: line.text }; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} fileContext | ||
| * @param {string} method | ||
| * @param {string} routePath | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function existingRouteBlock(fileContext, method, routePath) { | ||
| const wantedMethod = String(method || "GET").toUpperCase(); | ||
| for (const file of fileContext || []) { | ||
| for (const route of recordArray(file?.route_blocks)) { | ||
| if (String(route.method || "").toUpperCase() !== wantedMethod) continue; | ||
| if (String(route.path || "") !== routePath) continue; | ||
| return { | ||
| file: file.path || null, | ||
| line: route.line || null, | ||
| text: route.text || null | ||
| }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function textLabel(value) { | ||
| return String(value || "").replace(/\s+/g, " ").trim(); | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {boolean} | ||
| */ | ||
| function isRoleWord(value) { | ||
| return /^(staff|manager|admin|role)$/i.test(textLabel(value)); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @returns {{ common: string[], manager: string[], forbidden: string[], visibleActions: string[], stateCopy: string[], roleAffordances: string[], managerRoleAffordances: string[], minSections: number }} | ||
| */ | ||
| function dashboardTextPlan(targets) { | ||
| const common = new Set(); | ||
| const manager = new Set(); | ||
| const forbidden = new Set(); | ||
| const visibleActions = new Set(); | ||
| const stateCopy = new Set(); | ||
| const roleAffordances = new Set(); | ||
| const managerRoleAffordances = new Set(); | ||
| let minSections = 0; | ||
| for (const target of recordArray(targets)) { | ||
| minSections = Math.max(minSections, Number(target.min_sections || 0)); | ||
| const routePath = String(target.path || "/"); | ||
| const isManager = /[?&]role=manager\b|[?&]role=admin\b/i.test(routePath); | ||
| for (const value of recordArray(target.required_text).map((entry) => textLabel(String(entry))).filter(Boolean)) { | ||
| if (isRoleWord(value)) continue; | ||
| if (isManager) manager.add(value); | ||
| else if (!/^(Clinic Ops|Dashboard)$/i.test(value)) common.add(value); | ||
| } | ||
| if (!isManager) { | ||
| for (const value of recordArray(target.forbidden_text).map((entry) => textLabel(String(entry))).filter(Boolean)) forbidden.add(value); | ||
| } | ||
| const evidence = target.ux_evidence && typeof target.ux_evidence === "object" && !Array.isArray(target.ux_evidence) | ||
| ? target.ux_evidence | ||
| : {}; | ||
| for (const value of recordArray(evidence.visible_actions).map((entry) => textLabel(String(entry))).filter(Boolean)) { | ||
| visibleActions.add(value); | ||
| } | ||
| for (const value of recordArray(evidence.state_copy).map((entry) => textLabel(String(entry))).filter(Boolean)) { | ||
| stateCopy.add(value); | ||
| } | ||
| for (const value of recordArray(evidence.role_affordances).map((entry) => textLabel(String(entry))).filter(Boolean)) { | ||
| if (isManager || /^(manager|admin)$/i.test(value)) managerRoleAffordances.add(value); | ||
| else roleAffordances.add(value); | ||
| } | ||
| } | ||
| const filler = ["Today", "Open work", "Active follow-up", "Care team actions", "Saved work"]; | ||
| let index = 0; | ||
| while (common.size < Math.max(3, minSections - Math.max(0, manager.size)) && index < filler.length) { | ||
| common.add(filler[index]); | ||
| index += 1; | ||
| } | ||
| return { | ||
| common: [...common], | ||
| manager: [...manager].filter((value) => !common.has(value)), | ||
| forbidden: [...forbidden], | ||
| visibleActions: [...visibleActions], | ||
| stateCopy: [...stateCopy], | ||
| roleAffordances: [...roleAffordances], | ||
| managerRoleAffordances: [...managerRoleAffordances], | ||
| minSections | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @returns {string} | ||
| */ | ||
| function dashboardRouteBlock(targets) { | ||
| const plan = dashboardTextPlan(targets); | ||
| return ` if (req.method === "GET" && url.pathname === "/") {\n // topogram:experience dashboard start\n const role = url.searchParams.get("role") || "staff";\n const isManager = role === "manager" || role === "admin";\n const commonSections = ${JSON.stringify(plan.common)};\n const managerSections = ${JSON.stringify(plan.manager)};\n const visibleActions = ${JSON.stringify(plan.visibleActions)};\n const stateCopy = ${JSON.stringify(plan.stateCopy)};\n const roleAffordances = ${JSON.stringify(plan.roleAffordances)};\n const managerRoleAffordances = ${JSON.stringify(plan.managerRoleAffordances)};\n const sections = [...commonSections, ...(isManager ? managerSections : [])];\n const roles = [...roleAffordances, ...(isManager ? managerRoleAffordances : [])];\n const escapeHtml = (value) => String(value || "").replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\\"": """ }[char] || char));\n const listText = (values, fallback) => values.length ? values.join(", ") : fallback;\n const actionText = listText(visibleActions, "review");\n const stateText = listText(stateCopy, "active");\n const roleText = listText(roles, role);\n const actionButtons = visibleActions.map((action) => \`<li><button type="button" aria-label="\${escapeHtml(action)} action">\${escapeHtml(action)}</button></li>\`).join("");\n const stateBadges = stateCopy.map((state) => \`<li><span class="badge" aria-label="\${escapeHtml(state)} state">\${escapeHtml(state)}</span></li>\`).join("");\n const styles = \`<style>\n :root { color-scheme: light; --bg: #f4f6f8; --panel: #ffffff; --ink: #17202a; --muted: #5b6675; --line: #d7dee8; --action: #1d4ed8; --action-ink: #ffffff; }\n * { box-sizing: border-box; }\n body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }\n .topbar { background: #111827; color: white; padding: 20px 24px; }\n .topbar p { margin: 6px 0 0; color: #d1d5db; }\n .shell { max-width: 1120px; margin: 0 auto; padding: 20px; }\n .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }\n .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; box-shadow: 0 1px 2px rgba(15, 23, 42, .05); }\n .actions, .states { display: flex; flex-wrap: wrap; gap: 10px; padding: 0; margin: 12px 0 0; list-style: none; }\n button { min-height: 44px; padding: 0 14px; border-radius: 6px; border: 0; background: var(--action); color: var(--action-ink); font-weight: 700; }\n .badge { display: inline-flex; align-items: center; min-height: 32px; padding: 0 10px; border-radius: 999px; background: #e8eef8; color: #1f365c; font-weight: 700; }\n .muted { color: var(--muted); }\n @media (max-width: 640px) { .topbar { padding: 18px; } .shell { padding: 16px; } button { width: 100%; } .actions li { flex: 1 1 160px; } }\n </style>\`;\n const body = \`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Clinic Ops Dashboard</title>\${styles}</head><body><header class="topbar"><h1 id="dashboard-title">Clinic Ops Dashboard</h1><p>Role-aware workboard for \${escapeHtml(roleText)} users.</p></header><main class="shell" aria-labelledby="dashboard-title"><section class="card" aria-labelledby="overview-heading"><h2 id="overview-heading">Today in clinic operations</h2><p class="muted">Review active work, checkout ready visits, and follow-up on saved tasks without leaving the dashboard.</p><nav aria-label="Clinic dashboard actions"><ul class="actions">\${actionButtons}</ul></nav></section><div class="grid">\${sections.map((label) => \`<section class="card" aria-labelledby="\${escapeHtml(label).toLowerCase().replace(/[^a-z0-9]+/g, "-")}"><h2>\${escapeHtml(label)}</h2><p>\${escapeHtml(label)} work is active and actionable for the care team. Actions: \${escapeHtml(actionText)}. State: \${escapeHtml(stateText)}. Roles: \${escapeHtml(roleText)}.</p></section>\`).join("")}</div><section class="card" aria-labelledby="states-heading"><h2 id="states-heading">Actions and states</h2><p>Visible actions: \${escapeHtml(actionText)}. State copy: \${escapeHtml(stateText)}. Role-aware affordances: \${escapeHtml(roleText)}.</p><ul class="states">\${stateBadges}</ul></section></main></body></html>\`;\n res.writeHead(200, { "content-type": "text/html; charset=utf-8" });\n res.end(body);\n return;\n // topogram:experience dashboard end\n }`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @param {AnyRecord[]} fileContext | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function patchReadyForExperienceTargets(targets, fileContext) { | ||
| const activeTargets = recordArray(targets); | ||
| if (activeTargets.length === 0) return null; | ||
| const block = dashboardRouteBlock(activeTargets); | ||
| const existing = existingRouteBlock(fileContext, "GET", "/"); | ||
| if (existing?.text) { | ||
| return { | ||
| tool: "replace_file_text", | ||
| args: { path: existing.file || "server.mjs", search: existing.text, replacement: block }, | ||
| mode: "replace_existing_experience_route", | ||
| generated_code: block, | ||
| experience_target_ids: activeTargets.map((target) => target.id).filter(Boolean) | ||
| }; | ||
| } | ||
| const fallback = fallbackLine(fileContext); | ||
| if (fallback?.text) { | ||
| return { | ||
| tool: "replace_file_text", | ||
| args: { path: fallback.file || "server.mjs", search: fallback.text, replacement: `${block}\n\n${fallback.text}` }, | ||
| mode: "insert_experience_before_404_fallback", | ||
| generated_code: block, | ||
| experience_target_ids: activeTargets.map((target) => target.id).filter(Boolean) | ||
| }; | ||
| } | ||
| return { | ||
| tool: null, | ||
| args: null, | ||
| mode: "manual_experience_patch_required", | ||
| generated_code: block, | ||
| experience_target_ids: activeTargets.map((target) => target.id).filter(Boolean) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} input | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function patchReadyForTarget(input) { | ||
| const { contract, fileContext, seeds, fixtureSeeds, anchor, existingRoute, existingRouteBlock } = input; | ||
| const body = endpointRegionBody(contract, seeds, fixtureSeeds, fileContext); | ||
| const marker = `topogram:endpoint ${String(contract.id || "")}`; | ||
| const region = ` // ${marker} start\n${body}\n // ${marker} end`; | ||
| const block = endpointHandlerBlock(contract, body); | ||
| const markerSource = markerBlock(fileContext, marker); | ||
| if (markerSource?.text) { | ||
| return { | ||
| tool: "replace_file_text", | ||
| args: { path: markerSource.file || anchor.file || "server.mjs", search: markerSource.text, replacement: region }, | ||
| mode: "replace_marker_region", | ||
| generated_code: region, | ||
| note: "Apply this exact replacement, then run the proof command." | ||
| }; | ||
| } | ||
| if (existingRouteBlock?.text) { | ||
| return { | ||
| tool: "replace_file_text", | ||
| args: { path: existingRouteBlock.file || anchor.file || "server.mjs", search: existingRouteBlock.text, replacement: block }, | ||
| mode: "replace_existing_route_block", | ||
| generated_code: block, | ||
| note: "Use replace_file_text to replace the full existing route block with this exact handler block, then run the proof command." | ||
| }; | ||
| } | ||
| const fallback = fallbackLine(fileContext); | ||
| if (fallback?.text) { | ||
| return { | ||
| tool: "replace_file_text", | ||
| args: { path: fallback.file || anchor.file || "server.mjs", search: fallback.text, replacement: `${block}\n\n${fallback.text}` }, | ||
| mode: "insert_before_404_fallback", | ||
| generated_code: block, | ||
| note: "Apply one endpoint patch at a time; each replacement keeps the 404 fallback available for the next endpoint." | ||
| }; | ||
| } | ||
| return { | ||
| tool: null, | ||
| args: null, | ||
| mode: "manual_patch_required", | ||
| generated_code: block, | ||
| note: existingRoute?.text | ||
| ? "An existing route was found, but no full safe block anchor was available; patch that route manually and run the proof command." | ||
| : "No exact search anchor was available; add this handler before the 404 fallback." | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @param {CombinedPatchOptions} [options] | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function combinedPatchReadyForTargets(targets, options = {}) { | ||
| const experienceTargetList = recordArray(options.experienceTargets); | ||
| const fileContext = recordArray(options.fileContext); | ||
| const readyTargets = recordArray(targets) | ||
| .filter((target) => target?.patch_ready?.tool === "replace_file_text" && target.patch_ready.args?.path && target.patch_ready.args?.search); | ||
| if (readyTargets.length === 0) { | ||
| return fullServerReplacementForMinimalTargets(targets, { experienceTargets: experienceTargetList }); | ||
| } | ||
| /** @type {Map<string, AnyRecord[]>} */ | ||
| const fallbackGroups = new Map(); | ||
| /** @type {AnyRecord[]} */ | ||
| const toolCalls = []; | ||
| for (const target of readyTargets) { | ||
| const patch = target.patch_ready; | ||
| const args = patch.args || {}; | ||
| if (patch.mode === "insert_before_404_fallback") { | ||
| const key = `${args.path}\u0000${args.search}`; | ||
| const group = fallbackGroups.get(key) || []; | ||
| group.push(target); | ||
| fallbackGroups.set(key, group); | ||
| continue; | ||
| } | ||
| toolCalls.push({ | ||
| tool: "replace_file_text", | ||
| args: { | ||
| path: args.path, | ||
| search: args.search, | ||
| replacement: args.replacement | ||
| }, | ||
| target_ids: [target.endpoint_id || target.record_id || target.kind].filter(Boolean), | ||
| mode: patch.mode || "replace_file_text" | ||
| }); | ||
| } | ||
| const experiencePatch = patchReadyForExperienceTargets(experienceTargetList, fileContext); | ||
| if (experiencePatch?.tool === "replace_file_text" && experiencePatch.args?.path && experiencePatch.args?.search) { | ||
| if (experiencePatch.mode === "insert_experience_before_404_fallback") { | ||
| const key = `${experiencePatch.args.path}\u0000${experiencePatch.args.search}`; | ||
| const group = fallbackGroups.get(key) || []; | ||
| group.unshift({ | ||
| kind: "dashboard_experience", | ||
| experience_target_ids: experiencePatch.experience_target_ids || [], | ||
| patch_ready: experiencePatch | ||
| }); | ||
| fallbackGroups.set(key, group); | ||
| } else { | ||
| toolCalls.push({ | ||
| tool: "replace_file_text", | ||
| args: { | ||
| path: experiencePatch.args.path, | ||
| search: experiencePatch.args.search, | ||
| replacement: experiencePatch.args.replacement | ||
| }, | ||
| target_ids: experiencePatch.experience_target_ids || [], | ||
| mode: experiencePatch.mode || "replace_file_text" | ||
| }); | ||
| } | ||
| } | ||
| for (const group of fallbackGroups.values()) { | ||
| const first = group[0]; | ||
| const args = first.patch_ready.args || {}; | ||
| const blocks = group | ||
| .map((target) => String(target.patch_ready.generated_code || target.patch_ready.args?.replacement || "").trimEnd()) | ||
| .filter(Boolean); | ||
| toolCalls.unshift({ | ||
| tool: "replace_file_text", | ||
| args: { | ||
| path: args.path, | ||
| search: args.search, | ||
| replacement: `${blocks.join("\n\n")}\n\n${args.search}` | ||
| }, | ||
| target_ids: group.map((target) => target.endpoint_id || target.record_id || target.kind).filter(Boolean), | ||
| mode: group.length > 1 ? "combined_insert_before_404_fallback" : "insert_before_404_fallback" | ||
| }); | ||
| } | ||
| if (toolCalls.length === 0) return null; | ||
| const endpointIds = readyTargets.map((target) => target.endpoint_id).filter(Boolean); | ||
| const experienceTargetIds = experienceTargetList.map((target) => target.id).filter(Boolean); | ||
| return { | ||
| tool: "apply_work_next_action", | ||
| mode: toolCalls.length === 1 && toolCalls[0].mode === "combined_insert_before_404_fallback" | ||
| ? "combined_insert_before_404_fallback" | ||
| : "batch_replace_file_text", | ||
| endpoint_ids: endpointIds, | ||
| experience_target_ids: experienceTargetIds, | ||
| tool_calls: toolCalls, | ||
| runs_public_check: true, | ||
| note: "Apply this batch action to patch all ready endpoint targets, then run the public check." | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} targets | ||
| * @param {Pick<CombinedPatchOptions, "experienceTargets">} [options] | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function fullServerReplacementForMinimalTargets(targets, options = {}) { | ||
| const candidates = recordArray(targets).filter((target) => | ||
| target?.allow_full_file_replacement === true | ||
| && target?.patch_ready?.mode === "manual_patch_required" | ||
| && target.patch_ready.generated_code | ||
| && (target.file === "server.mjs" || target.patch_ready.args?.path === "server.mjs" || !target.file) | ||
| ); | ||
| if (candidates.length === 0 || candidates.length !== recordArray(targets).length) return null; | ||
| const blocks = candidates | ||
| .map((target) => String(target.patch_ready.generated_code || "").trimEnd()) | ||
| .filter(Boolean); | ||
| if (blocks.length === 0) return null; | ||
| return { | ||
| tool: "apply_work_next_action", | ||
| mode: "minimal_node_http_full_file_replacement", | ||
| endpoint_ids: candidates.map((target) => target.endpoint_id).filter(Boolean), | ||
| tool_calls: [{ | ||
| tool: "write_file", | ||
| args: { | ||
| path: "server.mjs", | ||
| content: minimalNodeHttpServerSource(blocks, { experienceTargets: recordArray(options.experienceTargets) }) | ||
| }, | ||
| target_ids: candidates.map((target) => target.endpoint_id || target.record_id || target.kind).filter(Boolean), | ||
| mode: "minimal_node_http_full_file_replacement" | ||
| }], | ||
| experience_target_ids: recordArray(options.experienceTargets).map((target) => target.id).filter(Boolean), | ||
| runs_public_check: true, | ||
| note: "The maintained app entrypoint is still a minimal placeholder, so this action replaces it with a deterministic node:http server for the current operation targets." | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string[]} endpointBlocks | ||
| * @param {Pick<CombinedPatchOptions, "experienceTargets">} [options] | ||
| * @returns {string} | ||
| */ | ||
| function minimalNodeHttpServerSource(endpointBlocks, options = {}) { | ||
| const experienceBlock = recordArray(options.experienceTargets).length > 0 | ||
| ? `\n${dashboardRouteBlock(recordArray(options.experienceTargets))}\n` | ||
| : ""; | ||
| return `import http from "node:http"; | ||
| import { readFileSync } from "node:fs"; | ||
| const port = Number(process.env.PORT || 3000); | ||
| let seed = {}; | ||
| try { | ||
| seed = JSON.parse(readFileSync(new URL("./seed-fixture.json", import.meta.url), "utf8")); | ||
| } catch { | ||
| seed = {}; | ||
| } | ||
| function sendJson(res, status, body) { | ||
| res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); | ||
| res.end(JSON.stringify(body)); | ||
| } | ||
| function readJsonBody(req) { | ||
| return new Promise((resolve) => { | ||
| let raw = ""; | ||
| req.on("data", (chunk) => { raw += chunk; }); | ||
| req.on("end", () => { | ||
| try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } | ||
| }); | ||
| req.on("error", () => resolve({})); | ||
| }); | ||
| } | ||
| function normalizeSeedKey(value) { | ||
| return String(value || "") | ||
| .replace(/^entity_/, "") | ||
| .replace(/([a-z0-9])([A-Z])/g, "$1_$2") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "_") | ||
| .replace(/^_+|_+$/g, ""); | ||
| } | ||
| function seedKeyVariants(entityId, keys) { | ||
| const variants = new Set([entityId, ...(keys || [])] | ||
| .map((value) => String(value || "").replace(/^entity_/, "")) | ||
| .map(normalizeSeedKey) | ||
| .filter(Boolean)); | ||
| for (const variant of [...variants]) { | ||
| if (variant.endsWith("s") && variant.length > 4) variants.add(variant.slice(0, -1)); | ||
| else variants.add(variant + "s"); | ||
| } | ||
| return variants; | ||
| } | ||
| function keyMatches(key, variants) { | ||
| return variants.has(normalizeSeedKey(key)); | ||
| } | ||
| function seedCandidates(entityId, keys) { | ||
| const variants = seedKeyVariants(entityId, keys); | ||
| const results = []; | ||
| function visit(value, path = []) { | ||
| const key = path.at(-1) || ""; | ||
| if (Array.isArray(value) && keyMatches(key, variants)) { | ||
| results.push(...value); | ||
| return; | ||
| } | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) return; | ||
| for (const [key, entry] of Object.entries(value)) visit(entry, [...path, key]); | ||
| } | ||
| visit(seed); | ||
| return results; | ||
| } | ||
| function seedCollection(entityId, keys) { | ||
| return seedCandidates(entityId, keys) | ||
| .filter((value) => value && typeof value === "object" && !Array.isArray(value)); | ||
| } | ||
| function seedObject(entityId, keys, fallback = {}) { | ||
| const variants = seedKeyVariants(entityId, keys); | ||
| function visit(value, path = []) { | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) return null; | ||
| for (const [key, entry] of Object.entries(value)) { | ||
| if (keyMatches(key, variants) && entry && typeof entry === "object" && !Array.isArray(entry)) { | ||
| return entry; | ||
| } | ||
| } | ||
| for (const [key, entry] of Object.entries(value)) { | ||
| const found = visit(entry, [...path, key]); | ||
| if (found) return found; | ||
| } | ||
| return null; | ||
| } | ||
| const direct = visit(seed); | ||
| if (direct) return direct; | ||
| return seedCollection(entityId, keys).find((value) => value && typeof value === "object" && !Array.isArray(value)) || fallback; | ||
| } | ||
| function seedSingle(entityId, keys, fallback = {}) { | ||
| return seedObject(entityId, keys, seedCollection(entityId, keys)[0] || fallback); | ||
| } | ||
| const server = http.createServer(async (req, res) => { | ||
| const url = new URL(req.url || "/", "http://127.0.0.1"); | ||
| ${experienceBlock} | ||
| if (req.method === "GET" && url.pathname === "/health") return sendJson(res, 200, { ok: true }); | ||
| ${endpointBlocks.join("\n\n")} | ||
| return sendJson(res, 404, { error: "not_found", path: url.pathname }); | ||
| }); | ||
| server.listen(port, "127.0.0.1", () => { | ||
| console.log("clinic ops node-http app listening on " + port); | ||
| }); | ||
| `; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {string|null|undefined} value | ||
| * @param {number} max | ||
| * @returns {string|null} | ||
| */ | ||
| function truncateText(value, max = 220) { | ||
| const text = value == null ? "" : String(value); | ||
| if (!text) return null; | ||
| return text.length > max ? `${text.slice(0, max - 3)}...` : text; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function recordArray(value) { | ||
| return Array.isArray(value) ? /** @type {AnyRecord[]} */ (value) : []; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} record | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function compactRecord(record) { | ||
| return Object.fromEntries(Object.entries(record).filter(([, value]) => { | ||
| if (value == null) return false; | ||
| if (Array.isArray(value) && value.length === 0) return false; | ||
| return true; | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} target | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function targetSummary(target) { | ||
| const patchReady = target.patch_ready || null; | ||
| return compactRecord({ | ||
| kind: target.kind || null, | ||
| file: target.file || null, | ||
| endpoint_id: target.endpoint_id || null, | ||
| capability_id: target.capability_id || null, | ||
| record_kind: target.record_kind || null, | ||
| record_id: target.record_id || null, | ||
| action: target.action || null, | ||
| method: target.method || null, | ||
| api_path: target.api_path || null, | ||
| expected_status: target.expected_status || null, | ||
| anchor_status: target.anchor_status || null, | ||
| insertion_anchor: target.insertion_anchor || null, | ||
| source_ref: target.source_ref || null, | ||
| has_snippet: Boolean(target.snippet), | ||
| has_patch_ready: Boolean(patchReady), | ||
| patch_ready_tool: patchReady?.tool || null, | ||
| patch_ready_mode: patchReady?.mode || null, | ||
| patch_ready_path: patchReady?.args?.path || null, | ||
| proof_command: target.proof_command || null, | ||
| patch_intent: truncateText(target.patch_intent, 220) | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} action | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function actionSummary(action) { | ||
| const toolCalls = recordArray(action.tool_calls); | ||
| const paths = [...new Set(toolCalls.map((call) => call?.args?.path).filter(Boolean).map(String))]; | ||
| const tools = [...new Set(toolCalls.map((call) => call?.tool).filter(Boolean).map(String))]; | ||
| return compactRecord({ | ||
| id: action.id || null, | ||
| kind: action.kind || null, | ||
| summary: action.summary || null, | ||
| allowed_state: action.allowed_state || null, | ||
| preferred: Boolean(action.preferred), | ||
| endpoint_ids: recordArray(action.endpoint_ids), | ||
| experience_target_ids: recordArray(action.experience_target_ids), | ||
| target_ids: [...new Set(toolCalls.flatMap((call) => recordArray(call?.target_ids)).map(String))], | ||
| tool_call_count: toolCalls.length, | ||
| tools, | ||
| paths, | ||
| runs_public_check: Boolean(action.runs_public_check), | ||
| success_condition: action.success_condition || null, | ||
| failure_behavior: action.failure_behavior || null | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} action | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function preferredActionSummary(action) { | ||
| if (!action) return null; | ||
| return compactRecord({ | ||
| id: action.id || null, | ||
| kind: action.kind || null, | ||
| summary: action.summary || null, | ||
| runs_public_check: Boolean(action.runs_public_check) | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} record | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function proposedModelSummary(record) { | ||
| return compactRecord({ | ||
| action: record.action || "create_model_record", | ||
| kind: record.kind || null, | ||
| id: record.id || null, | ||
| why: truncateText(record.why, 220), | ||
| has_snippet: Boolean(record.snippet), | ||
| file: record.file || null | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} patch | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| export function taskRecordEditSummary(patch) { | ||
| if (!patch) return null; | ||
| return compactRecord({ | ||
| task_id: patch.task_id || null, | ||
| action: patch.action || null, | ||
| feature_id: patch.feature_id || null, | ||
| model_file: patch.model_file || null, | ||
| generated_from_proposed_model_work: Boolean(patch.generated_from_proposed_model_work), | ||
| has_snippet: Boolean(patch.snippet), | ||
| note: truncateText(patch.note, 260) | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} contracts | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function contractSummary(contracts) { | ||
| const seedCounts = new Map(); | ||
| for (const seed of recordArray(contracts.seed_summaries)) { | ||
| const endpointId = seed.endpoint_id || "unknown"; | ||
| seedCounts.set(endpointId, (seedCounts.get(endpointId) || 0) + 1); | ||
| } | ||
| return { | ||
| endpoint_contracts: recordArray(contracts.endpoint_contracts).map((contract) => compactRecord({ | ||
| id: contract.id || null, | ||
| method: contract.method || null, | ||
| path: contract.path || null, | ||
| capability_id: contract.capability_id || null, | ||
| success_status: contract.success_status || null, | ||
| response_container: contract.response?.container || contract.response_container || null, | ||
| response_entity: contract.response?.entity_id || contract.response_entity || null, | ||
| response_result: contract.response?.result || contract.response_result || null, | ||
| verification_ids: Array.isArray(contract.verification_ids) ? contract.verification_ids : [], | ||
| seed_summary_count: seedCounts.get(contract.id) || 0 | ||
| })), | ||
| verification_ids: contracts.verification_ids || [] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} coverage | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function coverageSummary(coverage) { | ||
| const missingRequired = recordArray(coverage.missing_required_operations); | ||
| const required = recordArray(coverage.required_operations); | ||
| return compactRecord({ | ||
| sufficient: Boolean(coverage.sufficient), | ||
| coverage_mode: coverage.coverage_mode || null, | ||
| feature_id: coverage.feature_id || null, | ||
| missing_terms: Array.isArray(coverage.missing_terms) ? coverage.missing_terms : [], | ||
| advisory_terms: Array.isArray(coverage.advisory_terms) ? coverage.advisory_terms : [], | ||
| ignored_modifier_terms: Array.isArray(coverage.ignored_modifier_terms) ? coverage.ignored_modifier_terms : [], | ||
| feature_endpoint_ids: Array.isArray(coverage.feature_endpoint_ids) ? coverage.feature_endpoint_ids : [], | ||
| missing_feature_endpoint_ids: Array.isArray(coverage.missing_feature_endpoint_ids) ? coverage.missing_feature_endpoint_ids : [], | ||
| extra_feature_endpoint_ids: Array.isArray(coverage.extra_feature_endpoint_ids) ? coverage.extra_feature_endpoint_ids : [], | ||
| required_operation_count: required.length, | ||
| missing_required_operation_ids: missingRequired.map((operation) => operation.id || operation.path || null).filter(Boolean), | ||
| reason: truncateText(coverage.reason, 220) | ||
| }); | ||
| } |
| // @ts-check | ||
| import { stablePublicStringify } from "../../public-paths.js"; | ||
| import { buildWorkNextPacket } from "./work-next/next.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord} packet | ||
| */ | ||
| function printWorkNextHuman(packet) { | ||
| const lines = [ | ||
| `State: ${packet.state}`, | ||
| `Do now: ${packet.do_now}`, | ||
| `Success: ${packet.success_condition}`, | ||
| "" | ||
| ]; | ||
| if (packet.agent_packet?.edit_targets?.length) { | ||
| lines.push("Edit targets:"); | ||
| for (const target of packet.agent_packet.edit_targets.slice(0, 8)) { | ||
| const label = target.endpoint_id || target.record_id || target.task_id || target.diagnostic_id || target.kind; | ||
| lines.push(`- ${target.kind}: ${label || "target"}${target.file ? ` (${target.file})` : ""}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| if (packet.agent_packet?.actions?.length) { | ||
| lines.push("Packet actions:"); | ||
| for (const action of packet.agent_packet.actions.slice(0, 5)) { | ||
| lines.push(`- ${action.id}: ${action.summary || action.kind}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| if (packet.agent_packet?.drill_down?.length) { | ||
| lines.push("Drill down:"); | ||
| for (const command of packet.agent_packet.drill_down.slice(0, 5)) lines.push(`- ${command}`); | ||
| } | ||
| process.stdout.write(`${lines.join("\n").trimEnd()}\n`); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} packet | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function buildWorkAdvancePreview(packet) { | ||
| const preferred = packet.preferred_action || null; | ||
| const actionReady = Boolean(preferred?.id); | ||
| return { | ||
| type: "topogram_work_advance", | ||
| version: 1, | ||
| mode: packet.mode || "implementation", | ||
| state: packet.state || "model_missing", | ||
| status: actionReady ? "action_ready" : "decision_required", | ||
| do_now: actionReady | ||
| ? `Apply preferred action ${preferred.id}, then rerun work next.` | ||
| : packet.do_now, | ||
| success_condition: actionReady | ||
| ? preferred.success_condition || packet.success_condition | ||
| : packet.success_condition, | ||
| workflow_source: "topogram_work_next", | ||
| action_execution: "not_executed", | ||
| preferred_action: preferred, | ||
| actions: Array.isArray(packet.actions) ? packet.actions : [], | ||
| next_packet: packet.agent_packet || null, | ||
| checkpoint: packet.checkpoint || packet.agent_packet?.checkpoint || null, | ||
| stop_condition: actionReady | ||
| ? "A runner may apply this exact packet action and then rerun work next until no preferred action remains or an action runs proof." | ||
| : "No preferred automatic action is available; follow do_now and drill_down.", | ||
| caveats: [ | ||
| "work advance is read-only in the public CLI. It exposes the next executable packet action but does not edit files.", | ||
| "Automation runners may execute only actions emitted by this packet; they should record every edit and proof result." | ||
| ] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} packet | ||
| */ | ||
| function printWorkAdvanceHuman(packet) { | ||
| const lines = [ | ||
| `State: ${packet.state}`, | ||
| `Status: ${packet.status}`, | ||
| `Do now: ${packet.do_now}`, | ||
| `Success: ${packet.success_condition || ""}` | ||
| ]; | ||
| if (packet.preferred_action?.id) { | ||
| lines.push("", "Preferred action:"); | ||
| lines.push(`- ${packet.preferred_action.id}: ${packet.preferred_action.summary || packet.preferred_action.kind}`); | ||
| } | ||
| process.stdout.write(`${lines.join("\n").trimEnd()}\n`); | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * commandArgs: AnyRecord, | ||
| * inputPath: string, | ||
| * selectors: AnyRecord, | ||
| * modeId?: string|null, | ||
| * detailId?: string|null, | ||
| * includeFiles?: string[], | ||
| * implementerId?: string|null, | ||
| * appState?: string|null, | ||
| * json?: boolean | ||
| * }} options | ||
| * @returns {Promise<number>} | ||
| */ | ||
| export async function runWorkCommand(options) { | ||
| if (!["next", "advance"].includes(options.commandArgs.workCommand)) { | ||
| console.error("Unsupported work command. Use: topogram work next <path> --task <task-id> --mode implementation [--implementer <id>] [--app-state unknown|minimal_placeholder|maintained] --json"); | ||
| return 2; | ||
| } | ||
| if (!options.selectors.taskId) { | ||
| console.error(`topogram work ${options.commandArgs.workCommand} requires --task <task-id>.`); | ||
| return 2; | ||
| } | ||
| const packet = await buildWorkNextPacket({ | ||
| inputPath: options.inputPath, | ||
| selectors: options.selectors, | ||
| modeId: options.modeId || "implementation", | ||
| detailId: options.detailId || "compact", | ||
| includeFiles: options.includeFiles || [], | ||
| implementerId: options.implementerId || null, | ||
| appState: options.appState || null | ||
| }); | ||
| if (options.commandArgs.workCommand === "advance") { | ||
| const advance = buildWorkAdvancePreview(packet); | ||
| if (options.json) { | ||
| console.log(stablePublicStringify(advance, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| printWorkAdvanceHuman(advance); | ||
| } | ||
| return packet.state === "model_invalid" ? 1 : 0; | ||
| } | ||
| if (options.json) { | ||
| console.log(stablePublicStringify(packet, { projectRoot: process.cwd(), cwd: process.cwd() })); | ||
| } else { | ||
| printWorkNextHuman(packet); | ||
| } | ||
| return packet.state === "model_invalid" ? 1 : 0; | ||
| } |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { buildAgentBrief } from "../../agent-brief.js"; | ||
| import { | ||
| buildChangePlanPayload, | ||
| buildRiskSummaryPayload, | ||
| classifyRisk, | ||
| proceedDecisionFromRisk | ||
| } from "../../agent-ops/query-builders.js"; | ||
| import { generateApiContractGraph } from "../api.js"; | ||
| import { generateUiWidgetContract } from "../widgets.js"; | ||
| import { generateDbTarget } from "../surfaces/databases/index.js"; | ||
| import { generateAppTarget } from "../surfaces/index.js"; | ||
| import { stableStringify } from "../../format.js"; | ||
| import { collectTopogramFiles, parsePath } from "../../parser.js"; | ||
| import { replaceKnownPathSubstrings, sanitizePublicPayload, toPortablePath } from "../../public-paths.js"; | ||
| import { auditWorkspace } from "../../sdlc/audit.js"; | ||
| import { readHistory } from "../../sdlc/history.js"; | ||
| import { loadSdlcPolicy, policyProjectRoot } from "../../sdlc/policy.js"; | ||
| import { buildSdlcGroomingPayload } from "../../sdlc/grooming.js"; | ||
| import { buildSdlcReadyPayload } from "../../sdlc/ready.js"; | ||
| import { readVerificationRuns } from "../../sdlc/verification-runs.js"; | ||
| import { | ||
| buildSdlcBacklogPayload, | ||
| buildSdlcBlockersPayload, | ||
| buildSdlcMetricsPayload, | ||
| buildSdlcProofGapsPayload | ||
| } from "../../sdlc/views.js"; | ||
| import { APPROX_CHARS_PER_TOKEN, textTokenStats } from "../../token-estimate.js"; | ||
| import { generateContextDiff } from "./diff.js"; | ||
| import { generateContextReport } from "./report.js"; | ||
| import { generateContextSlice } from "./slice.js"; | ||
| import { formatContextSliceHtml } from "./slice/html.js"; | ||
| import { generateContextTaskMode } from "./task-mode.js"; | ||
| import { generateDomainList } from "./domain-coverage.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| * @typedef {{ path: string, contents: any }} AuditFile | ||
| */ | ||
| const PROFILES = new Set(["standard", "adoption", "bug", "experiment"]); | ||
| const SELECTOR_FLAGS = [ | ||
| ["capabilityId", "--capability"], | ||
| ["workflowId", "--workflow"], | ||
| ["projectionId", "--surface"], | ||
| ["screenId", "--screen"], | ||
| ["layoutId", "--layout"], | ||
| ["regionId", "--region"], | ||
| ["designRealizationSetId", "--component-map"], | ||
| ["widgetId", "--widget"], | ||
| ["componentId", "--widget"], | ||
| ["entityId", "--entity"], | ||
| ["journeyId", "--journey"], | ||
| ["domainId", "--domain"], | ||
| ["featureId", "--feature"], | ||
| ["pitchId", "--pitch"], | ||
| ["requirementId", "--requirement"], | ||
| ["acceptanceId", "--acceptance"], | ||
| ["taskId", "--task"], | ||
| ["planId", "--plan"], | ||
| ["bugId", "--bug"], | ||
| ["documentId", "--document"] | ||
| ]; | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function safeSegment(value) { | ||
| return String(value || "workspace").replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "workspace"; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function selectorSummary(options) { | ||
| /** @type {AnyRecord} */ | ||
| const selector = {}; | ||
| const seenFlags = new Set(); | ||
| for (const [key, flag] of SELECTOR_FLAGS) { | ||
| if (seenFlags.has(flag)) continue; | ||
| const value = options[key]; | ||
| if (value) { | ||
| selector[flag.replace(/^--/, "").replace(/-/g, "_")] = value; | ||
| seenFlags.add(flag); | ||
| } | ||
| } | ||
| return selector; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {boolean} | ||
| */ | ||
| function hasSelector(options) { | ||
| return Object.keys(selectorSummary(options)).length > 0; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {string} | ||
| */ | ||
| function selectorArgs(options) { | ||
| const parts = []; | ||
| const seenFlags = new Set(); | ||
| for (const [key, flag] of SELECTOR_FLAGS) { | ||
| if (seenFlags.has(flag)) continue; | ||
| const value = options[key]; | ||
| if (value) { | ||
| parts.push(flag, String(value)); | ||
| seenFlags.add(flag); | ||
| } | ||
| } | ||
| return parts.join(" "); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function sliceOptions(options) { | ||
| return { | ||
| capabilityId: options.capabilityId, | ||
| workflowId: options.workflowId, | ||
| projectionId: options.projectionId || options.surfaceId, | ||
| screenId: options.screenId, | ||
| layoutId: options.layoutId, | ||
| regionId: options.regionId, | ||
| designRealizationSetId: options.designRealizationSetId, | ||
| widgetId: options.widgetId || options.componentId, | ||
| componentId: options.componentId || options.widgetId, | ||
| entityId: options.entityId, | ||
| journeyId: options.journeyId, | ||
| surfaceId: options.surfaceId || options.projectionId, | ||
| domainId: options.domainId, | ||
| featureId: options.featureId, | ||
| pitchId: options.pitchId, | ||
| requirementId: options.requirementId, | ||
| acceptanceId: options.acceptanceId, | ||
| taskId: options.taskId, | ||
| planId: options.planId, | ||
| bugId: options.bugId, | ||
| documentId: options.documentId, | ||
| modeId: options.modeId, | ||
| detailId: options.detailId, | ||
| fromTopogramPath: options.fromTopogramPath | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {string} id | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function recordById(graph, id) { | ||
| for (const values of Object.values(graph.byKind || {})) { | ||
| if (!Array.isArray(values)) continue; | ||
| const found = values.find((entry) => entry?.id === id); | ||
| if (found) return found; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @param {Set<string>} ids | ||
| * @returns {void} | ||
| */ | ||
| function collectIds(value, ids) { | ||
| if (!value) return; | ||
| if (typeof value === "string") { | ||
| ids.add(value); | ||
| return; | ||
| } | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) collectIds(item, ids); | ||
| return; | ||
| } | ||
| if (typeof value === "object") { | ||
| const record = /** @type {AnyRecord} */ (value); | ||
| if (typeof record.id === "string") ids.add(record.id); | ||
| for (const child of Object.values(record)) collectIds(child, ids); | ||
| } | ||
| } | ||
| /** | ||
| * @param {string} filePath | ||
| * @returns {string} | ||
| */ | ||
| function toPosix(filePath) { | ||
| return filePath.split(path.sep).join("/"); | ||
| } | ||
| /** | ||
| * @param {string} root | ||
| * @param {string} filePath | ||
| * @returns {string} | ||
| */ | ||
| function relativeSourcePath(root, filePath) { | ||
| const absoluteRoot = path.resolve(root); | ||
| const absoluteFile = path.resolve(filePath); | ||
| const relative = path.relative(absoluteRoot, absoluteFile); | ||
| if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { | ||
| throw new Error(`Audit source excerpt path escapes the Topogram root: ${filePath}`); | ||
| } | ||
| return toPosix(relative); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord|null} slice | ||
| * @param {AnyRecord} options | ||
| * @returns {Set<string>} | ||
| */ | ||
| function evidenceIds(graph, slice, options) { | ||
| const ids = new Set(); | ||
| for (const value of Object.values(selectorSummary(options))) { | ||
| if (typeof value === "string") ids.add(value); | ||
| } | ||
| if (slice) { | ||
| collectIds(slice.focus, ids); | ||
| collectIds(slice.work_items, ids); | ||
| collectIds(slice.relationships, ids); | ||
| collectIds(slice.proof_plan, ids); | ||
| collectIds(slice.depends_on, ids); | ||
| } | ||
| if (!slice && !hasSelector(options)) { | ||
| for (const kind of ["task", "bug", "requirement", "acceptance_criterion", "rule"]) { | ||
| for (const record of graph.byKind?.[kind] || []) { | ||
| if (["in-progress", "unclaimed", "open", "approved", "enforced"].includes(String(record.status || ""))) { | ||
| ids.add(record.id); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return ids; | ||
| } | ||
| /** | ||
| * @param {string} root | ||
| * @param {AnyRecord[]} records | ||
| * @param {AnyRecord} context | ||
| * @returns {{ files: AuditFile[], index: AnyRecord }} | ||
| */ | ||
| function buildSourceEvidence(root, records, context) { | ||
| const byFile = new Map(); | ||
| /** @type {AnyRecord[]} */ | ||
| const index = []; | ||
| for (const record of records) { | ||
| const file = record?.loc?.file; | ||
| if (!file || typeof file !== "string" || !file.endsWith(".tg")) continue; | ||
| const relative = relativeSourcePath(root, file); | ||
| const loc = record.loc || {}; | ||
| const startLine = Number.isInteger(loc.start?.line) ? loc.start.line : 1; | ||
| const endLine = Number.isInteger(loc.end?.line) ? loc.end.line : startLine; | ||
| const ranges = byFile.get(relative) || []; | ||
| ranges.push({ | ||
| id: record.id, | ||
| kind: record.kind, | ||
| start_line: Math.max(1, startLine - 2), | ||
| end_line: Math.max(startLine, endLine + 2) | ||
| }); | ||
| byFile.set(relative, ranges); | ||
| } | ||
| /** @type {AuditFile[]} */ | ||
| const files = []; | ||
| for (const [relative, ranges] of [...byFile.entries()].sort(([left], [right]) => left.localeCompare(right))) { | ||
| const sourcePath = path.join(root, relative); | ||
| const lines = fs.readFileSync(sourcePath, "utf8").split(/\r?\n/); | ||
| const sections = []; | ||
| for (const range of ranges.sort( | ||
| /** | ||
| * @param {AnyRecord} left | ||
| * @param {AnyRecord} right | ||
| * @returns {number} | ||
| */ | ||
| (left, right) => left.start_line - right.start_line || String(left.id).localeCompare(String(right.id)) | ||
| )) { | ||
| const text = lines.slice(range.start_line - 1, range.end_line).join("\n"); | ||
| sections.push([ | ||
| `# Source evidence: ${relative}`, | ||
| `# Record: ${range.kind} ${range.id}`, | ||
| `# Lines: ${range.start_line}-${range.end_line}`, | ||
| replaceKnownPathSubstrings(text, context) | ||
| ].join("\n")); | ||
| index.push({ | ||
| id: range.id, | ||
| kind: range.kind, | ||
| source_path: relative, | ||
| excerpt_path: `source/excerpts/${relative}`, | ||
| start_line: range.start_line, | ||
| end_line: range.end_line | ||
| }); | ||
| } | ||
| files.push({ | ||
| path: `source/excerpts/${relative}`, | ||
| contents: `${sections.join("\n\n")}\n` | ||
| }); | ||
| } | ||
| return { | ||
| files, | ||
| index: { | ||
| type: "audit_source_index", | ||
| version: 1, | ||
| files_count: byFile.size, | ||
| excerpts_count: index.length, | ||
| excerpts: index | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} topogramRoot | ||
| * @param {AnyRecord|null} workspaceAst | ||
| * @param {AnyRecord|null} slice | ||
| * @param {AnyRecord} context | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function buildContextSavings(topogramRoot, workspaceAst, slice, context) { | ||
| const sliceText = stableStringify(slice || {}); | ||
| const files = fs.existsSync(topogramRoot) ? collectTopogramFiles(topogramRoot) : []; | ||
| const topoSource = files.map((file) => fs.readFileSync(file, "utf8")).join("\n"); | ||
| const ast = workspaceAst || (fs.existsSync(topogramRoot) ? parsePath(topogramRoot) : null); | ||
| const brief = ast ? buildAgentBrief(topogramRoot, ast) : null; | ||
| const briefText = stableStringify(brief?.ok ? brief.payload : {}); | ||
| const baselineText = `${briefText}\n${topoSource}`; | ||
| const sliceStats = textTokenStats(sliceText); | ||
| const baselineStats = textTokenStats(baselineText); | ||
| const savings = Math.max(0, baselineStats.estimated_tokens - sliceStats.estimated_tokens); | ||
| return { | ||
| type: "context_savings_query", | ||
| version: 1, | ||
| selector: slice?.focus || null, | ||
| detail_level: slice?.detail_level || "standard", | ||
| tokenizer: { | ||
| kind: "approximate", | ||
| chars_per_token: APPROX_CHARS_PER_TOKEN | ||
| }, | ||
| slice: { | ||
| format: slice ? "json" : "none", | ||
| ...sliceStats | ||
| }, | ||
| baseline_proxy: { | ||
| name: "agent_brief_plus_full_topogram", | ||
| files_count: files.length, | ||
| ...baselineStats | ||
| }, | ||
| upper_bound_savings: { | ||
| estimated_tokens: savings, | ||
| percent: baselineStats.estimated_tokens > 0 ? Number(((savings / baselineStats.estimated_tokens) * 100).toFixed(2)) : 0 | ||
| }, | ||
| transcript: null, | ||
| caveats: [ | ||
| "Estimated with approximate characters-per-token counting.", | ||
| `Paths are portable relative to ${toPortablePath(topogramRoot, context)}.` | ||
| ] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function focusedChangeArtifacts(graph, options) { | ||
| const taskMode = generateContextTaskMode(graph, { ...options, modeId: options.modeId || "modeling" }); | ||
| const slice = hasSelector(options) ? generateContextSlice(/** @type {any} */ (graph), options) : null; | ||
| const diff = options.fromTopogramPath ? generateContextDiff(graph, options) : null; | ||
| const changePlan = buildChangePlanPayload({ | ||
| graph, | ||
| taskModeArtifact: taskMode, | ||
| sliceArtifact: slice, | ||
| diffArtifact: diff, | ||
| maintainedBoundaryArtifact: null | ||
| }); | ||
| const risk = classifyRisk({ | ||
| reviewBoundary: changePlan.review_boundary, | ||
| diffSummary: changePlan.diff_summary || null, | ||
| verificationTargets: changePlan.verification_targets, | ||
| maintainedRisk: null | ||
| }); | ||
| return { | ||
| taskMode, | ||
| slice, | ||
| diff, | ||
| changePlan, | ||
| riskSummary: buildRiskSummaryPayload({ | ||
| source: "change-plan", | ||
| risk, | ||
| nextAction: changePlan.next_action || null, | ||
| maintainedRisk: null | ||
| }), | ||
| proceedDecision: proceedDecisionFromRisk( | ||
| risk, | ||
| changePlan.next_action || null, | ||
| changePlan.write_scope || null, | ||
| changePlan.verification_targets || null, | ||
| null, | ||
| null, | ||
| null | ||
| ) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function focusedContracts(graph, options) { | ||
| const contracts = []; | ||
| const widgetId = options.widgetId || options.componentId; | ||
| const surfaceId = options.projectionId || options.surfaceId; | ||
| if (options.capabilityId) { | ||
| contracts.push({ | ||
| path: "contracts/api-contract-graph.json", | ||
| kind: "api_contract", | ||
| contents: generateApiContractGraph(/** @type {any} */ (graph), { capabilityId: options.capabilityId }) | ||
| }); | ||
| } | ||
| if (widgetId) { | ||
| contracts.push({ | ||
| path: "contracts/ui-widget-contract.json", | ||
| kind: "ui_widget_contract", | ||
| contents: generateUiWidgetContract(graph, { widgetId }) | ||
| }); | ||
| } | ||
| if (surfaceId) { | ||
| const surface = (graph.byKind?.surface || []).find( | ||
| /** | ||
| * @param {AnyRecord} entry | ||
| * @returns {boolean} | ||
| */ | ||
| (entry) => entry.id === surfaceId | ||
| ); | ||
| if (surface?.type === "db" || surface?.type === "database") { | ||
| contracts.push({ | ||
| path: "contracts/db-contract-graph.json", | ||
| kind: "db_contract", | ||
| contents: generateDbTarget("db-contract-graph", graph, { projectionId: surfaceId }) | ||
| }); | ||
| } else if (surface?.type === "api") { | ||
| contracts.push({ | ||
| path: "contracts/server-contract.json", | ||
| kind: "server_contract", | ||
| contents: generateAppTarget("server-contract", graph, { projectionId: surfaceId }) | ||
| }); | ||
| } else { | ||
| contracts.push({ | ||
| path: "contracts/ui-surface-contract.json", | ||
| kind: "ui_surface_contract", | ||
| contents: generateAppTarget("ui-surface-contract", graph, { projectionId: surfaceId }) | ||
| }); | ||
| contracts.push({ | ||
| path: "reports/work-map.json", | ||
| kind: "work_map", | ||
| contents: generateAppTarget("work-map-report", graph, { projectionId: surfaceId, screenId: options.screenId }) | ||
| }); | ||
| } | ||
| } | ||
| return contracts; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord|null} slice | ||
| * @param {AnyRecord} options | ||
| * @param {string} topogramRoot | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function bugProfileReports(graph, slice, options, topogramRoot) { | ||
| const runs = readVerificationRuns(topogramRoot).runs; | ||
| const bug = options.bugId ? recordById(graph, options.bugId) : null; | ||
| const taskIds = new Set([ | ||
| options.taskId, | ||
| ...(bug?.fixedIn || []).map(/** @param {AnyRecord|string} entry */ (entry) => typeof entry === "string" ? entry : entry?.id), | ||
| ...(slice?.depends_on?.fixed_in || []) | ||
| ].filter(Boolean)); | ||
| const verificationIds = new Set([ | ||
| ...(bug?.fixedInVerification || []).map(/** @param {AnyRecord|string} entry */ (entry) => typeof entry === "string" ? entry : entry?.id), | ||
| ...(slice?.proof_plan?.required?.verification_ids || []), | ||
| ...(slice?.depends_on?.verifications || []) | ||
| ].filter(Boolean)); | ||
| return { | ||
| proofGaps: [...taskIds].length > 0 | ||
| ? Object.fromEntries([...taskIds].map((taskId) => [taskId, buildSdlcProofGapsPayload(graph, taskId, runs)])) | ||
| : buildSdlcProofGapsPayload(graph, null, runs), | ||
| blockers: [...taskIds].length > 0 | ||
| ? Object.fromEntries([...taskIds].map((taskId) => [taskId, buildSdlcBlockersPayload(graph, taskId)])) | ||
| : buildSdlcBlockersPayload(graph, null), | ||
| verificationRuns: { | ||
| type: "verification_runs_query", | ||
| version: 1, | ||
| task_ids: [...taskIds], | ||
| verification_ids: [...verificationIds], | ||
| runs: runs | ||
| .filter((run) => taskIds.size === 0 || taskIds.has(run.task_id)) | ||
| .filter((run) => verificationIds.size === 0 || verificationIds.has(run.verification_id)) | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} options | ||
| * @param {string} topogramRoot | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function adoptionProfileReports(graph, options, topogramRoot) { | ||
| const runs = readVerificationRuns(topogramRoot).runs; | ||
| const history = readHistory(topogramRoot); | ||
| const policy = loadSdlcPolicy(policyProjectRoot(topogramRoot)).policy; | ||
| return { | ||
| workspaceCheck: { | ||
| type: "audit_workspace_check", | ||
| version: 1, | ||
| ok: true, | ||
| topogram: { | ||
| files: collectTopogramFiles(topogramRoot).length, | ||
| statements: graph.statements?.length || 0, | ||
| valid: true | ||
| } | ||
| }, | ||
| sdlcAudit: auditWorkspace(topogramRoot, { graph }), | ||
| sdlcReady: buildSdlcReadyPayload(graph, runs), | ||
| sdlcBacklog: buildSdlcBacklogPayload(graph), | ||
| sdlcGrooming: buildSdlcGroomingPayload(graph), | ||
| sdlcMetrics: buildSdlcMetricsPayload(graph, history, policy, { verificationRuns: runs }), | ||
| contextReport: generateContextReport(graph, options), | ||
| domainList: generateDomainList(graph) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} slice | ||
| * @param {AnyRecord} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function experimentProfileWorkflow(slice, options) { | ||
| const selectorText = selectorArgs(options); | ||
| const taskId = options.taskId || slice?.focus?.id || "<task-id>"; | ||
| const mode = options.modeId || "implementation"; | ||
| return { | ||
| type: "experiment_expected_workflow", | ||
| version: 1, | ||
| focus: slice?.focus || null, | ||
| selector: selectorSummary(options), | ||
| expected_packet_command: `topogram work next ./topo --task ${taskId} --mode ${mode} --json`, | ||
| expected_trace_command: "topogram trace analyze <run-dir> --audit-bundle <bundle-dir> --json", | ||
| expected_commands: [ | ||
| `topogram work next ./topo ${selectorText} --mode ${mode} --detail compact --json`.replace(/\s+/g, " ").trim(), | ||
| "topogram check . --json", | ||
| "topogram emit audit-bundle ./topo --profile experiment --write --out-dir ./artifacts" | ||
| ], | ||
| proof_targets: slice?.proof_plan || slice?.verification_targets || null, | ||
| caveats: [ | ||
| "This artifact records expected workflow evidence; it does not execute work next or app verification.", | ||
| "Use trace analyze after a run to compare these expectations with observed tool calls and outcomes." | ||
| ] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} manifest | ||
| * @returns {string} | ||
| */ | ||
| function renderReadme(manifest) { | ||
| const rows = manifest.artifacts.map( | ||
| /** | ||
| * @param {AnyRecord} artifact | ||
| * @returns {string} | ||
| */ | ||
| (artifact) => | ||
| `| \`${artifact.path}\` | ${artifact.kind} | ${artifact.why} |` | ||
| ).join("\n"); | ||
| return `# Topogram Audit Bundle | ||
| Focus: ${manifest.focus ? `${manifest.focus.kind} \`${manifest.focus.id}\`` : "workspace"} | ||
| Profile: \`${manifest.profile}\` | ||
| This bundle is deterministic local evidence for the selected Topogram context. | ||
| It contains derived reports, selected source excerpts, and proof-oriented | ||
| packets that can be reviewed without re-running discovery. | ||
| | Artifact | Kind | Why | | ||
| | --- | --- | --- | | ||
| ${rows} | ||
| `; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} options | ||
| * @returns {{ bundle_id: string, manifest: AnyRecord, files: AuditFile[] }} | ||
| */ | ||
| export function generateAuditBundle(graph, options = {}) { | ||
| const profile = String(options.profileId || "standard").toLowerCase(); | ||
| if (!PROFILES.has(profile)) { | ||
| throw new Error(`Unsupported audit-bundle profile '${options.profileId}'. Use standard, adoption, bug, or experiment.`); | ||
| } | ||
| const topogramRoot = path.resolve(options.inputPath || options.workspaceRoot || graph.root || options.topogramInputPath || "."); | ||
| const projectRoot = path.resolve(options.projectRoot || options.configDir || topogramRoot); | ||
| const publicContext = { | ||
| projectRoot, | ||
| workspaceRoot: topogramRoot, | ||
| topogramRoot, | ||
| cwd: options.cwd || process.cwd() | ||
| }; | ||
| const selectors = selectorSummary(options); | ||
| const focused = hasSelector(options); | ||
| const effectiveOptions = sliceOptions(options); | ||
| const changeArtifacts = focused ? focusedChangeArtifacts(graph, effectiveOptions) : null; | ||
| const slice = changeArtifacts?.slice || null; | ||
| const focus = slice?.focus || null; | ||
| const bundleId = safeSegment(focus?.id || (focused ? Object.values(selectors)[0] : "workspace")); | ||
| /** @type {AuditFile[]} */ | ||
| const files = []; | ||
| /** @type {AnyRecord[]} */ | ||
| const artifacts = []; | ||
| const caveats = []; | ||
| /** | ||
| * @param {string} filePath | ||
| * @param {any} contents | ||
| * @param {string} kind | ||
| * @param {string} format | ||
| * @param {string} why | ||
| * @param {string|null} sourceCommand | ||
| * @returns {void} | ||
| */ | ||
| function add(filePath, contents, kind, format, why, sourceCommand = null) { | ||
| files.push({ path: filePath, contents }); | ||
| artifacts.push({ path: filePath, kind, format, why, source_command: sourceCommand }); | ||
| } | ||
| const selectorText = selectorArgs(effectiveOptions); | ||
| if (slice) { | ||
| add("slice/context-slice.json", slice, "context_slice", "json", "Canonical focused slice used by agents.", `topogram query slice ./topo ${selectorText} --detail ${options.detailId || "standard"} --json`.replace(/\s+/g, " ").trim()); | ||
| add("slice/context-slice.html", formatContextSliceHtml(sanitizePublicPayload(slice, publicContext)), "context_slice", "html", "Human-readable cockpit for the focused slice.", `topogram query slice ./topo ${selectorText} --detail ${options.detailId || "standard"} --format html`.replace(/\s+/g, " ").trim()); | ||
| } else { | ||
| caveats.push("No selector was provided, so this is a workspace-level audit bundle without a focused context slice."); | ||
| } | ||
| if (slice || focused) { | ||
| add("reports/context-savings.json", buildContextSavings(topogramRoot, options.workspaceAst || null, slice, publicContext), "context_savings", "json", "Estimated context savings for the focused packet.", `topogram query context-savings ./topo ${selectorText} --detail ${options.detailId || "standard"} --json`.replace(/\s+/g, " ").trim()); | ||
| } | ||
| if (changeArtifacts) { | ||
| add("reports/write-scope.json", { | ||
| type: "write_scope_query", | ||
| source: "context-slice", | ||
| focus, | ||
| summary: slice?.summary || null, | ||
| write_scope: slice?.write_scope || changeArtifacts.taskMode.write_scope || null | ||
| }, "write_scope", "json", "Safe and risky edit boundaries for the selected focus.", `topogram query write-scope ./topo ${selectorText} --json`.replace(/\s+/g, " ").trim()); | ||
| add("reports/verification-targets.json", { | ||
| type: "verification_targets_query", | ||
| source: "context-slice", | ||
| focus, | ||
| verification_targets: slice?.verification_targets || changeArtifacts.taskMode.verification_targets || null | ||
| }, "verification_targets", "json", "Proof targets that should be considered before closeout.", `topogram query verification-targets ./topo ${selectorText} --json`.replace(/\s+/g, " ").trim()); | ||
| add("reports/change-plan.json", changeArtifacts.changePlan, "change_plan", "json", "Semantic change plan assembled from the selected slice.", `topogram query change-plan ./topo ${selectorText} --json`.replace(/\s+/g, " ").trim()); | ||
| add("reports/risk-summary.json", changeArtifacts.riskSummary, "risk_summary", "json", "Review, ownership, and verification risk summary.", `topogram query risk-summary ./topo ${selectorText} --json`.replace(/\s+/g, " ").trim()); | ||
| add("reports/proceed-decision.json", changeArtifacts.proceedDecision, "proceed_decision", "json", "Decision packet for whether enough context exists to proceed.", `topogram query proceed-decision ./topo ${selectorText} --json`.replace(/\s+/g, " ").trim()); | ||
| if (changeArtifacts.diff) { | ||
| add("reports/context-diff.json", changeArtifacts.diff, "context_diff", "json", "Semantic diff against the requested baseline.", `topogram query diff ./topo --from-topogram <baseline> --json`); | ||
| } | ||
| for (const contract of focusedContracts(graph, effectiveOptions)) { | ||
| add(contract.path, contract.contents, contract.kind, "json", "Contract artifact relevant to the selected focus.", null); | ||
| } | ||
| } | ||
| if (profile === "bug") { | ||
| const reports = bugProfileReports(graph, slice, effectiveOptions, topogramRoot); | ||
| add("reports/sdlc-proof-gaps.json", reports.proofGaps, "sdlc_proof_gaps", "json", "Proof gaps for tasks related to the bug focus.", null); | ||
| add("reports/sdlc-blockers.json", reports.blockers, "sdlc_blockers", "json", "Blockers for tasks related to the bug focus.", null); | ||
| add("reports/verification-runs.json", reports.verificationRuns, "verification_runs", "json", "Recorded verification evidence for the bug focus.", null); | ||
| } | ||
| if (profile === "experiment") { | ||
| add("reports/expected-workflow.json", experimentProfileWorkflow(slice, effectiveOptions), "expected_workflow", "json", "Expected workflow evidence for trace analysis of experiment or agent runs.", null); | ||
| } | ||
| if (profile === "adoption" || !focused) { | ||
| const reports = adoptionProfileReports(graph, effectiveOptions, topogramRoot); | ||
| const ast = options.workspaceAst || parsePath(topogramRoot); | ||
| const brief = buildAgentBrief(topogramRoot, ast); | ||
| add("reports/agent-brief.json", brief.ok ? brief.payload : brief, "agent_brief", "json", "Current first-run briefing for agents and human supervisors.", "topogram agent brief . --json"); | ||
| add("reports/workspace-check.json", reports.workspaceCheck, "workspace_check", "json", "Validation summary for the parsed Topogram workspace.", "topogram check . --json"); | ||
| add("reports/sdlc-audit.json", reports.sdlcAudit, "sdlc_audit", "json", "SDLC hygiene audit used during first adoption.", "topogram sdlc audit . --json"); | ||
| add("reports/sdlc-ready.json", reports.sdlcReady, "sdlc_ready", "json", "Startable work, blockers, and proof evidence.", "topogram query sdlc-ready ./topo --json"); | ||
| add("reports/sdlc-backlog.json", reports.sdlcBacklog, "sdlc_backlog", "json", "Draft and shaped work that needs grooming.", "topogram query sdlc-backlog ./topo --json"); | ||
| add("reports/sdlc-grooming.json", reports.sdlcGrooming, "sdlc_grooming", "json", "Post-work cleanup and transition candidates.", "topogram query sdlc-grooming ./topo --json"); | ||
| add("reports/sdlc-metrics.json", reports.sdlcMetrics, "sdlc_metrics", "json", "Lightweight SDLC flow and proof metrics.", "topogram query sdlc-metrics ./topo --json"); | ||
| add("reports/context-report.json", reports.contextReport, "context_report", "json", "Workspace-level context size and slice inventory.", "topogram emit context-report ./topo --json"); | ||
| add("reports/domain-list.json", reports.domainList, "domain_list", "json", "Domain inventory for workspace orientation.", "topogram query domain-list ./topo --json"); | ||
| } | ||
| const ids = evidenceIds(graph, slice, effectiveOptions); | ||
| /** @type {AnyRecord[]} */ | ||
| const records = []; | ||
| for (const id of ids) { | ||
| const record = recordById(graph, id); | ||
| if (record) records.push(record); | ||
| } | ||
| const sourceEvidence = buildSourceEvidence(topogramRoot, records, publicContext); | ||
| add("source/source-index.json", sourceEvidence.index, "source_index", "json", "Portable index of source excerpts included in this audit bundle.", null); | ||
| for (const file of sourceEvidence.files) { | ||
| add(file.path, file.contents, "source_excerpt", "tg", "Bounded source evidence for records included in the audit bundle.", null); | ||
| } | ||
| const manifest = { | ||
| type: "audit_bundle", | ||
| version: 1, | ||
| profile, | ||
| focus, | ||
| selector: selectors, | ||
| detail_level: options.detailId || "standard", | ||
| bundle_id: bundleId, | ||
| artifacts, | ||
| caveats | ||
| }; | ||
| add("audit-manifest.json", manifest, "audit_manifest", "json", "Index of every artifact included in this audit bundle.", null); | ||
| add("README.md", renderReadme(manifest), "readme", "markdown", "Human-readable entrypoint for the bundle.", null); | ||
| return { | ||
| bundle_id: bundleId, | ||
| manifest, | ||
| files | ||
| }; | ||
| } |
| export function generateContextDiff(graph: any, options?: any): any; |
| export function generateDomainCoverage(graph: any, options?: any): any; | ||
| export function generateDomainList(graph: any): any; |
| export function generateContextReport(graph: any, options?: any): any; |
| // @ts-check | ||
| import path from "node:path"; | ||
| import { stableStringify } from "../../../format.js"; | ||
| import { APPROX_CHARS_PER_TOKEN, textTokenStats } from "../../../token-estimate.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const RELATION_METADATA = { | ||
| acceptance_refs: { | ||
| relation: "acceptance_ref", | ||
| kind: "acceptance_criterion", | ||
| tier: "must_read", | ||
| why: "Defines an explicit done condition for this work." | ||
| }, | ||
| affects: { | ||
| relation: "affects", | ||
| kind: null, | ||
| tier: "must_read", | ||
| why: "Identifies a capability or surface that can change behavior." | ||
| }, | ||
| blocked_by: { | ||
| relation: "blocked_by", | ||
| kind: "task", | ||
| tier: "must_read", | ||
| why: "Blocks this work until resolved." | ||
| }, | ||
| blocks: { | ||
| relation: "blocks", | ||
| kind: "task", | ||
| tier: "reference", | ||
| why: "Shows downstream work that may be affected." | ||
| }, | ||
| capabilities: { | ||
| relation: "uses_capability", | ||
| kind: "capability", | ||
| tier: "must_read", | ||
| why: "Defines behavior this slice may touch." | ||
| }, | ||
| design_languages: { | ||
| relation: "uses_design_language", | ||
| kind: "design_language", | ||
| tier: "reference", | ||
| why: "Carries design-token and platform mapping context." | ||
| }, | ||
| documents: { | ||
| relation: "references_document", | ||
| kind: "document", | ||
| tier: "reference", | ||
| why: "Provides supporting narrative or evidence." | ||
| }, | ||
| entities: { | ||
| relation: "uses_entity", | ||
| kind: "entity", | ||
| tier: "must_read", | ||
| why: "Defines data this slice may read or mutate." | ||
| }, | ||
| journeys: { | ||
| relation: "uses_journey", | ||
| kind: "journey", | ||
| tier: "must_read", | ||
| why: "Defines user or workflow order relevant to the work." | ||
| }, | ||
| layouts: { | ||
| relation: "uses_layout", | ||
| kind: "layout", | ||
| tier: "must_read", | ||
| why: "Defines screen structure and region inheritance." | ||
| }, | ||
| plans: { | ||
| relation: "uses_plan", | ||
| kind: "plan", | ||
| tier: "must_read", | ||
| why: "Defines implementation steps or remaining work." | ||
| }, | ||
| projections: { | ||
| relation: "uses_surface", | ||
| kind: "surface", | ||
| tier: "must_read", | ||
| why: "Defines the app or runtime surface for this work." | ||
| }, | ||
| regions: { | ||
| relation: "uses_region", | ||
| kind: "region", | ||
| tier: "must_read", | ||
| why: "Defines a checkable UI work area." | ||
| }, | ||
| requirements: { | ||
| relation: "uses_requirement", | ||
| kind: "requirement", | ||
| tier: "must_read", | ||
| why: "Defines product or engineering intent." | ||
| }, | ||
| rules: { | ||
| relation: "respects_rule", | ||
| kind: "rule", | ||
| tier: "must_read", | ||
| why: "Defines a repo law that constrains implementation choices." | ||
| }, | ||
| satisfies: { | ||
| relation: "satisfies", | ||
| kind: "requirement", | ||
| tier: "must_read", | ||
| why: "Defines the requirement this work is meant to satisfy." | ||
| }, | ||
| shapes: { | ||
| relation: "uses_shape", | ||
| kind: "shape", | ||
| tier: "must_read", | ||
| why: "Defines input or output structure this work may rely on." | ||
| }, | ||
| surfaces: { | ||
| relation: "uses_surface", | ||
| kind: "surface", | ||
| tier: "must_read", | ||
| why: "Defines the app or runtime surface for this work." | ||
| }, | ||
| terms: { | ||
| relation: "uses_term", | ||
| kind: "term", | ||
| tier: "reference", | ||
| why: "Defines vocabulary needed to interpret the slice." | ||
| }, | ||
| verifications: { | ||
| relation: "proved_by", | ||
| kind: "verification", | ||
| tier: "proof", | ||
| why: "Defines proof that should run before completion." | ||
| }, | ||
| verification_refs: { | ||
| relation: "verification_ref", | ||
| kind: "verification", | ||
| tier: "proof", | ||
| why: "Directly linked proof for this work." | ||
| }, | ||
| widgets: { | ||
| relation: "uses_widget", | ||
| kind: "widget", | ||
| tier: "must_read", | ||
| why: "Defines reusable UI behavior or presentation in scope." | ||
| }, | ||
| workflows: { | ||
| relation: "uses_workflow", | ||
| kind: "workflow", | ||
| tier: "must_read", | ||
| why: "Defines state, transition, or operating flow in scope." | ||
| } | ||
| }; | ||
| /** | ||
| * @param {string} key | ||
| * @returns {keyof typeof RELATION_METADATA} | ||
| */ | ||
| function normalizedRelationKey(key) { | ||
| return /** @type {keyof typeof RELATION_METADATA} */ (String(key || "").replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {string|null} | ||
| */ | ||
| function idFromRef(value) { | ||
| if (typeof value === "string") return value; | ||
| if (value && typeof value === "object") { | ||
| const record = /** @type {AnyRecord} */ (value); | ||
| return typeof record.id === "string" ? record.id : null; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {string} id | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function recordById(graph, id) { | ||
| const statement = (graph.statements || []).find(/** @param {AnyRecord} entry */ (entry) => entry.id === id); | ||
| if (statement) return statement; | ||
| const doc = (graph.docs || []).find(/** @param {AnyRecord} entry */ (entry) => entry.id === id); | ||
| return doc || null; | ||
| } | ||
| /** | ||
| * @param {string} value | ||
| * @returns {string} | ||
| */ | ||
| function toPosix(value) { | ||
| return value.split(path.sep).join("/"); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord|null} record | ||
| * @returns {{ file: string, line?: number }|null} | ||
| */ | ||
| function sourceRefForRecord(graph, record) { | ||
| if (!record || typeof record !== "object") return null; | ||
| const sourceFile = record.loc?.file || record.relativePath || record.file || null; | ||
| if (!sourceFile || typeof sourceFile !== "string") return null; | ||
| const root = typeof graph.root === "string" ? graph.root : process.cwd(); | ||
| const relative = path.isAbsolute(sourceFile) | ||
| ? path.relative(root, sourceFile) | ||
| : sourceFile; | ||
| if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null; | ||
| const sourceRef = { | ||
| file: toPosix(relative) | ||
| }; | ||
| const line = record.loc?.start?.line; | ||
| if (Number.isInteger(line)) { | ||
| return { ...sourceRef, line }; | ||
| } | ||
| return sourceRef; | ||
| } | ||
| /** | ||
| * @param {unknown[]} refs | ||
| * @returns {string[]} | ||
| */ | ||
| function idsFromRefs(refs) { | ||
| return [...new Set((refs || []).map(idFromRef).filter( | ||
| /** @param {string|null} id @returns {id is string} */ | ||
| (id) => Boolean(id) | ||
| ))].sort(); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} capability | ||
| * @returns {string|null} | ||
| */ | ||
| function defaultEntityIdForCapability(capability) { | ||
| if (!capability) return null; | ||
| const candidates = [ | ||
| ...(capability.creates || []), | ||
| ...(capability.reads || []), | ||
| ...(capability.updates || []), | ||
| ...(capability.deletes || []) | ||
| ]; | ||
| return candidates.map(idFromRef).find(Boolean) || null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} endpoint | ||
| * @param {AnyRecord|null|undefined} capability | ||
| * @returns {{ result: string|null, container: string|null, entity_id: string|null, inferred: boolean }} | ||
| */ | ||
| function endpointResponseIntent(endpoint, capability) { | ||
| const explicitResult = endpoint.responseResult || null; | ||
| const explicitContainer = endpoint.responseContainer || null; | ||
| const explicitEntity = endpoint.responseEntity?.id || null; | ||
| const inferredResult = endpoint.method === "GET" && (endpoint.id || "").startsWith("endpoint_list_") | ||
| ? "collection" | ||
| : endpoint.method === "GET" && (capability?.id || "").startsWith("cap_list_") | ||
| ? "collection" | ||
| : endpoint.success === 204 | ||
| ? "none" | ||
| : "item"; | ||
| const result = explicitResult || inferredResult; | ||
| return { | ||
| result, | ||
| container: explicitContainer || (result === "collection" ? "json_array" : result === "none" ? "none" : "json_object"), | ||
| entity_id: explicitEntity || (result === "none" ? null : defaultEntityIdForCapability(capability)), | ||
| inferred: !(explicitResult && explicitContainer && (explicitEntity || explicitResult === "none")) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {string[]} entityIds | ||
| * @returns {Map<string, AnyRecord[]>} | ||
| */ | ||
| function seedRecordsByEntity(graph, entityIds) { | ||
| const wanted = new Set(entityIds.filter(Boolean)); | ||
| const byEntity = new Map(); | ||
| for (const seed of graph.byKind?.seed_data || []) { | ||
| const entityId = seed.entity?.id || null; | ||
| if (!entityId || !wanted.has(entityId)) continue; | ||
| const entries = byEntity.get(entityId) || []; | ||
| entries.push(seed); | ||
| byEntity.set(entityId, entries); | ||
| } | ||
| return byEntity; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} slice | ||
| * @returns {string[]} | ||
| */ | ||
| function capabilityIdsForImplementationContracts(graph, slice) { | ||
| const ids = new Set(); | ||
| if (slice.focus?.kind === "capability" && slice.focus.id) ids.add(slice.focus.id); | ||
| for (const id of idsFromRefs(slice.depends_on?.affects || [])) { | ||
| const record = recordById(graph, id); | ||
| if (record?.kind === "capability") ids.add(id); | ||
| if (record?.kind === "endpoint" && record.capability?.id) ids.add(record.capability.id); | ||
| } | ||
| if (ids.size > 0) return [...ids].sort(); | ||
| const verificationIds = [ | ||
| ...idsFromRefs(slice.depends_on?.verification_refs || []), | ||
| ...idsFromRefs(slice.depends_on?.verifications || []) | ||
| ]; | ||
| for (const verificationId of verificationIds) { | ||
| const verification = recordById(graph, verificationId); | ||
| for (const targetId of idsFromRefs(verification?.validates || [])) { | ||
| const target = recordById(graph, targetId); | ||
| if (target?.kind === "capability") ids.add(targetId); | ||
| } | ||
| } | ||
| return [...ids].sort(); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {string} capabilityId | ||
| * @returns {string[]} | ||
| */ | ||
| function verificationIdsForCapability(graph, capabilityId) { | ||
| return (graph.byKind?.verification || []) | ||
| .filter(/** @param {AnyRecord} verification */ (verification) => idsFromRefs(verification.validates || []).includes(capabilityId)) | ||
| .map(/** @param {AnyRecord} verification */ (verification) => verification.id) | ||
| .filter(Boolean) | ||
| .sort(); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} seeds | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function seedExamples(seeds) { | ||
| return seeds.slice(0, 3).map((seed) => ({ | ||
| id: seed.id, | ||
| purpose: seed.purpose || null, | ||
| records: (seed.records || []).slice(0, 3).map(/** @param {AnyRecord} record */ (record) => ({ | ||
| id: record.id, | ||
| fields: record.fields || {} | ||
| })) | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} slice | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function buildSliceImplementationContracts(graph, slice) { | ||
| const capabilityIds = capabilityIdsForImplementationContracts(graph, slice); | ||
| if (capabilityIds.length === 0) return []; | ||
| const capabilities = new Map((graph.byKind?.capability || []).map(/** @param {AnyRecord} capability */ (capability) => [capability.id, capability])); | ||
| const endpoints = (graph.byKind?.endpoint || []) | ||
| .filter(/** @param {AnyRecord} endpoint */ (endpoint) => capabilityIds.includes(endpoint.capability?.id)) | ||
| .sort(/** @param {AnyRecord} left @param {AnyRecord} right */ (left, right) => String(left.id).localeCompare(String(right.id))); | ||
| const entityIds = endpoints | ||
| .map(/** @param {AnyRecord} endpoint */ (endpoint) => endpointResponseIntent(endpoint, capabilities.get(endpoint.capability?.id)).entity_id) | ||
| .filter(Boolean); | ||
| const seedsByEntity = seedRecordsByEntity(graph, entityIds); | ||
| return endpoints.map(/** @param {AnyRecord} endpoint */ (endpoint) => { | ||
| const capability = capabilities.get(endpoint.capability?.id) || null; | ||
| const response = endpointResponseIntent(endpoint, capability); | ||
| return { | ||
| kind: "endpoint", | ||
| id: endpoint.id, | ||
| capability_id: capability?.id || endpoint.capability?.id || null, | ||
| method: endpoint.method || null, | ||
| path: endpoint.path || null, | ||
| success_status: endpoint.success || null, | ||
| auth: endpoint.auth || null, | ||
| request: endpoint.request || null, | ||
| response, | ||
| seed_examples: response.entity_id ? seedExamples(seedsByEntity.get(response.entity_id) || []) : [], | ||
| verification_ids: capability?.id ? verificationIdsForCapability(graph, capability.id) : [], | ||
| source_ref: sourceRefForRecord(graph, endpoint) | ||
| }; | ||
| }); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} record | ||
| * @param {string} fallbackId | ||
| * @param {string|null} fallbackKind | ||
| * @returns {{ id: string, kind: string|null, name: string|null, description: string|null, status: string|null }} | ||
| */ | ||
| function recordSummary(record, fallbackId, fallbackKind) { | ||
| return { | ||
| id: fallbackId, | ||
| kind: record?.kind || fallbackKind || null, | ||
| name: record?.name || record?.title || null, | ||
| description: record?.description || record?.summary || null, | ||
| status: record?.status || null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} slice | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function buildSliceRelationships(graph, slice) { | ||
| const focus = slice.focus || {}; | ||
| const relationships = []; | ||
| const dependsOn = slice.depends_on || {}; | ||
| for (const [rawKey, values] of Object.entries(dependsOn)) { | ||
| if (!Array.isArray(values)) continue; | ||
| const key = normalizedRelationKey(rawKey); | ||
| if (key === "verifications" && Array.isArray(dependsOn.verification_refs) && dependsOn.verification_refs.length > 0) { | ||
| continue; | ||
| } | ||
| const metadata = RELATION_METADATA[key] || { | ||
| relation: key, | ||
| kind: null, | ||
| tier: "reference", | ||
| why: "Provides related context for this slice." | ||
| }; | ||
| for (const value of values) { | ||
| const id = idFromRef(value); | ||
| if (!id) continue; | ||
| const record = recordById(graph, id); | ||
| relationships.push({ | ||
| from: { | ||
| kind: focus.kind || null, | ||
| id: focus.id || null | ||
| }, | ||
| to: recordSummary(record, id, metadata.kind), | ||
| relation: metadata.relation, | ||
| tier: metadata.tier, | ||
| why: metadata.why | ||
| }); | ||
| } | ||
| } | ||
| return relationships; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} item | ||
| * @returns {string} | ||
| */ | ||
| function suggestedActionForItem(item) { | ||
| if (item.role === "focus") return "Start here and keep the work bounded to this focus."; | ||
| if (item.kind === "verification") return "Run or inspect this proof before declaring the work complete."; | ||
| if (item.kind === "rule") return "Respect this rule while choosing the implementation approach."; | ||
| if (item.kind === "acceptance_criterion") return "Use this as a done condition."; | ||
| if (item.kind === "requirement") return "Use this as the source of product or engineering intent."; | ||
| if (item.kind === "term") return "Use this vocabulary consistently in code, docs, and reports."; | ||
| return "Inspect this record when the change touches its behavior or ownership boundary."; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} graph | ||
| * @param {AnyRecord} slice | ||
| * @param {AnyRecord[]} relationships | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function buildSliceWorkItems(graph, slice, relationships) { | ||
| const seen = new Set(); | ||
| const items = []; | ||
| const focusId = slice.focus?.id || null; | ||
| const focusRecord = focusId ? recordById(graph, focusId) : null; | ||
| if (focusId) { | ||
| const summary = recordSummary(focusRecord, focusId, slice.focus?.kind || null); | ||
| const item = { | ||
| ...summary, | ||
| role: "focus", | ||
| tier: "must_read", | ||
| why_included: "This is the selected graph focus for the slice.", | ||
| source_ref: sourceRefForRecord(graph, focusRecord) | ||
| }; | ||
| items.push({ | ||
| ...item, | ||
| suggested_action: suggestedActionForItem(item) | ||
| }); | ||
| seen.add(`${summary.kind}:${summary.id}`); | ||
| } | ||
| for (const relationship of relationships) { | ||
| const target = relationship.to || {}; | ||
| if (!target.id) continue; | ||
| const key = `${target.kind || "unknown"}:${target.id}`; | ||
| if (seen.has(key)) continue; | ||
| seen.add(key); | ||
| const record = recordById(graph, target.id); | ||
| const item = { | ||
| ...recordSummary(record, target.id, target.kind || null), | ||
| role: relationship.relation, | ||
| tier: relationship.tier, | ||
| why_included: relationship.why, | ||
| source_ref: sourceRefForRecord(graph, record) | ||
| }; | ||
| items.push({ | ||
| ...item, | ||
| suggested_action: suggestedActionForItem(item) | ||
| }); | ||
| } | ||
| return items; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} slice | ||
| * @param {AnyRecord} agentGuidance | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildSliceProofPlan(slice, agentGuidance) { | ||
| const requiredCommands = [...new Set(agentGuidance.proof_commands || [])]; | ||
| const nextCommands = [...new Set(agentGuidance.next_commands || [])]; | ||
| const recommendedCommands = nextCommands | ||
| .filter((command) => !requiredCommands.includes(command)) | ||
| .filter((command) => !/query slice\b/.test(command)); | ||
| const directVerificationIds = [...new Set(slice.depends_on?.verification_refs || [])].filter(Boolean); | ||
| const targetVerificationIds = [...new Set([ | ||
| ...(slice.verification_targets?.verification_ids || []), | ||
| ...(slice.depends_on?.verifications || []) | ||
| ])].filter(Boolean); | ||
| const verificationIds = directVerificationIds.length > 0 ? directVerificationIds : targetVerificationIds; | ||
| const recommendedVerificationIds = directVerificationIds.length > 0 | ||
| ? targetVerificationIds.filter((id) => !directVerificationIds.includes(id)) | ||
| : []; | ||
| const runtimeVerificationIds = (slice.verification || []) | ||
| .filter(/** @param {AnyRecord} verification */ (verification) => verification.method === "runtime") | ||
| .map(/** @param {AnyRecord} verification */ (verification) => verification.id) | ||
| .filter(/** @param {string} id */ (id) => verificationIds.includes(id)) | ||
| .filter(Boolean); | ||
| return { | ||
| required: { | ||
| commands: requiredCommands, | ||
| verification_ids: verificationIds | ||
| }, | ||
| recommended: { | ||
| commands: recommendedCommands, | ||
| verification_ids: recommendedVerificationIds | ||
| }, | ||
| expensive: { | ||
| commands: [], | ||
| verification_ids: [...new Set(runtimeVerificationIds)] | ||
| }, | ||
| already_satisfied: { | ||
| commands: [], | ||
| verification_ids: [] | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} slice | ||
| * @param {AnyRecord} proofPlan | ||
| * @param {AnyRecord} agentGuidance | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildSliceFrame(slice, proofPlan, agentGuidance) { | ||
| const summary = slice.summary || {}; | ||
| const focus = slice.focus || {}; | ||
| const proofCommand = proofPlan.required?.commands?.[0] || null; | ||
| const nextAction = (agentGuidance.next_commands || []) | ||
| .find(/** @param {string} command */ (command) => !/query slice\b/.test(command)) || | ||
| proofCommand || | ||
| "Read the must-read sections, then inspect proof requirements."; | ||
| const priority = String(summary.priority || "").toLowerCase(); | ||
| const automation = String(slice.review_boundary?.automation_class || "").toLowerCase(); | ||
| const risk = priority === "high" || automation.includes("manual") || automation.includes("review") | ||
| ? "review_required" | ||
| : "normal"; | ||
| return { | ||
| goal: summary.goal || summary.description || (summary.name ? `Work on ${summary.name}` : `Understand ${focus.kind || "item"} ${focus.id || ""}`.trim()), | ||
| current_state: summary.status || "unknown", | ||
| done_when: proofCommand | ||
| ? "Required proof commands pass and linked acceptance criteria remain satisfied." | ||
| : "The slice focus is understood and no blocking diagnostics remain.", | ||
| non_goals: [ | ||
| "Do not broaden the work beyond the selected focus unless a blocker or proof command requires it." | ||
| ], | ||
| risk, | ||
| recommended_next_action: nextAction | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} source | ||
| * @param {string} fieldPath | ||
| * @returns {unknown} | ||
| */ | ||
| function valueAtPath(source, fieldPath) { | ||
| return fieldPath.split(".").reduce((current, part) => { | ||
| if (!current || typeof current !== "object") return undefined; | ||
| return /** @type {AnyRecord} */ (current)[part]; | ||
| }, /** @type {unknown} */ (source)); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} slice | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildSliceAttentionBudget(slice) { | ||
| const sections = (slice.slice_manifest?.sections || []).map(/** @param {AnyRecord} section */ (section) => { | ||
| const value = valueAtPath(slice, section.field || section.id); | ||
| return { | ||
| id: section.id, | ||
| title: section.title, | ||
| tier: section.tier, | ||
| ...textTokenStats(stableStringify(value || null)) | ||
| }; | ||
| }); | ||
| const total = textTokenStats(stableStringify({ | ||
| ...slice, | ||
| attention_budget: undefined | ||
| })); | ||
| return { | ||
| tokenizer: { | ||
| kind: "approximate", | ||
| chars_per_token: APPROX_CHARS_PER_TOKEN | ||
| }, | ||
| total, | ||
| sections | ||
| }; | ||
| } |
| // @ts-check | ||
| import { stableStringify } from "../../../format.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {string} | ||
| */ | ||
| function text(value) { | ||
| if (value == null) return ""; | ||
| if (Array.isArray(value)) return value.join(", "); | ||
| return String(value); | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {string} | ||
| */ | ||
| function escapeHtml(value) { | ||
| return text(value) | ||
| .replace(/&/g, "&") | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, """) | ||
| .replace(/'/g, "'"); | ||
| } | ||
| /** | ||
| * @param {unknown[]} values | ||
| * @returns {string} | ||
| */ | ||
| function badges(values) { | ||
| return values | ||
| .filter(Boolean) | ||
| .map((value) => `<span class="badge">${escapeHtml(value)}</span>`) | ||
| .join(""); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} item | ||
| * @returns {string} | ||
| */ | ||
| function itemLabel(item) { | ||
| if (!item) return ""; | ||
| return item.name || item.title || item.id || ""; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} command | ||
| * @returns {string} | ||
| */ | ||
| function commandBlock(command) { | ||
| return command ? `<code>${escapeHtml(command)}</code>` : ""; | ||
| } | ||
| /** | ||
| * @param {string} heading | ||
| * @param {string[]|null|undefined} commands | ||
| * @returns {string} | ||
| */ | ||
| function commandList(heading, commands) { | ||
| const rows = (commands || []).filter(Boolean); | ||
| if (rows.length === 0) return ""; | ||
| return `<section class="card"> | ||
| <h3>${escapeHtml(heading)}</h3> | ||
| <ul class="command-list"> | ||
| ${rows.map((command) => `<li>${commandBlock(command)}</li>`).join("\n ")} | ||
| </ul> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} frame | ||
| * @returns {string} | ||
| */ | ||
| function renderFrame(frame) { | ||
| if (!frame) return ""; | ||
| return `<section class="hero" id="start-here"> | ||
| <div> | ||
| <p class="eyebrow">Start Here</p> | ||
| <h2>${escapeHtml(frame.goal || "Context slice")}</h2> | ||
| <p>${escapeHtml(frame.done_when || "")}</p> | ||
| </div> | ||
| <div class="hero-side"> | ||
| ${badges([frame.current_state, frame.risk])} | ||
| <strong>Next action</strong> | ||
| <p>${escapeHtml(frame.recommended_next_action || "")}</p> | ||
| </div> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]|null|undefined} items | ||
| * @returns {string} | ||
| */ | ||
| function renderWorkItems(items) { | ||
| const rows = Array.isArray(items) ? items : []; | ||
| if (rows.length === 0) return ""; | ||
| return `<section id="work-items"> | ||
| <h2>Work Items</h2> | ||
| <div class="grid"> | ||
| ${rows.slice(0, 24).map((item) => `<article class="card"> | ||
| <div class="card-top">${badges([item.tier, item.kind, item.role])}</div> | ||
| <h3>${escapeHtml(itemLabel(item))}</h3> | ||
| <p class="id">${escapeHtml(item.id)}</p> | ||
| <p>${escapeHtml(item.why_included)}</p> | ||
| <p><strong>Action:</strong> ${escapeHtml(item.suggested_action)}</p> | ||
| ${item.source_ref?.file ? `<p class="source">${escapeHtml(item.source_ref.file)}${item.source_ref.line ? `:${escapeHtml(item.source_ref.line)}` : ""}</p>` : ""} | ||
| </article>`).join("\n ")} | ||
| </div> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]|null|undefined} contracts | ||
| * @returns {string} | ||
| */ | ||
| function renderImplementationContracts(contracts) { | ||
| const rows = Array.isArray(contracts) ? contracts : []; | ||
| if (rows.length === 0) return ""; | ||
| return `<section id="implementation-contracts"> | ||
| <h2>Implementation Contracts</h2> | ||
| <table> | ||
| <thead><tr><th>Endpoint</th><th>Capability</th><th>Response</th><th>Seed examples</th></tr></thead> | ||
| <tbody> | ||
| ${rows.slice(0, 80).map(/** @param {AnyRecord} entry */ (entry) => { | ||
| const response = entry.response || {}; | ||
| const seeds = (entry.seed_examples || []).flatMap(/** @param {AnyRecord} seed */ (seed) => | ||
| (seed.records || []).map(/** @param {AnyRecord} record */ (record) => record.id) | ||
| ).filter(Boolean).slice(0, 5); | ||
| return `<tr> | ||
| <td><code>${escapeHtml(entry.method)} ${escapeHtml(entry.path)}</code><br><span class="muted">${escapeHtml(entry.id)} -> ${escapeHtml(entry.success_status)}</span></td> | ||
| <td><code>${escapeHtml(entry.capability_id || "")}</code></td> | ||
| <td>${badges([response.result, response.container, response.inferred ? "inferred" : "modeled"])}${response.entity_id ? `<br><code>${escapeHtml(response.entity_id)}</code>` : ""}</td> | ||
| <td>${seeds.length > 0 ? seeds.map(/** @param {string} id */ (id) => `<code>${escapeHtml(id)}</code>`).join(" ") : "<span class=\"muted\">None</span>"}</td> | ||
| </tr>`; | ||
| }).join("\n ")} | ||
| </tbody> | ||
| </table> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]|null|undefined} relationships | ||
| * @returns {string} | ||
| */ | ||
| function renderRelationships(relationships) { | ||
| const rows = Array.isArray(relationships) ? relationships : []; | ||
| if (rows.length === 0) return ""; | ||
| return `<section id="relationships"> | ||
| <h2>Relationship Map</h2> | ||
| <table> | ||
| <thead><tr><th>Relation</th><th>Target</th><th>Tier</th><th>Why included</th></tr></thead> | ||
| <tbody> | ||
| ${rows.slice(0, 80).map((entry) => `<tr> | ||
| <td>${escapeHtml(entry.relation)}</td> | ||
| <td><code>${escapeHtml(entry.to?.kind || "item")}:${escapeHtml(entry.to?.id || "")}</code></td> | ||
| <td>${badges([entry.tier])}</td> | ||
| <td>${escapeHtml(entry.why)}</td> | ||
| </tr>`).join("\n ")} | ||
| </tbody> | ||
| </table> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} proofPlan | ||
| * @returns {string} | ||
| */ | ||
| function renderProofPlan(proofPlan) { | ||
| if (!proofPlan) return ""; | ||
| return `<section id="proof"> | ||
| <h2>Proof Plan</h2> | ||
| <div class="grid two"> | ||
| ${commandList("Required Commands", proofPlan.required?.commands)} | ||
| ${commandList("Recommended Commands", proofPlan.recommended?.commands)} | ||
| <section class="card"> | ||
| <h3>Required Verification</h3> | ||
| <p>${(proofPlan.required?.verification_ids || []).map(/** @param {string} id */ (id) => `<code>${escapeHtml(id)}</code>`).join(" ") || "None listed."}</p> | ||
| </section> | ||
| <section class="card"> | ||
| <h3>Expensive Runtime Proof</h3> | ||
| <p>${(proofPlan.expensive?.verification_ids || []).map(/** @param {string} id */ (id) => `<code>${escapeHtml(id)}</code>`).join(" ") || "None listed."}</p> | ||
| </section> | ||
| </div> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} writeScope | ||
| * @returns {string} | ||
| */ | ||
| function renderWriteScope(writeScope) { | ||
| if (!writeScope) return ""; | ||
| const groups = Object.entries(writeScope) | ||
| .filter(([, value]) => Array.isArray(value) && value.length > 0); | ||
| if (groups.length === 0) return ""; | ||
| return `<section id="write-scope"> | ||
| <h2>Write Scope</h2> | ||
| <div class="grid"> | ||
| ${groups.map(([key, values]) => `<article class="card"> | ||
| <h3>${escapeHtml(key.replace(/_/g, " "))}</h3> | ||
| <p>${/** @type {string[]} */ (values).map((value) => `<code>${escapeHtml(value)}</code>`).join(" ")}</p> | ||
| </article>`).join("\n ")} | ||
| </div> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} budget | ||
| * @returns {string} | ||
| */ | ||
| function renderAttentionBudget(budget) { | ||
| if (!budget) return ""; | ||
| return `<section id="attention"> | ||
| <h2>Attention Budget</h2> | ||
| <table> | ||
| <thead><tr><th>Section</th><th>Tier</th><th>Estimated Tokens</th><th>Bytes</th></tr></thead> | ||
| <tbody> | ||
| ${(budget.sections || []).map(/** @param {AnyRecord} section */ (section) => `<tr> | ||
| <td>${escapeHtml(section.title || section.id)}</td> | ||
| <td>${badges([section.tier])}</td> | ||
| <td>${escapeHtml(section.estimated_tokens)}</td> | ||
| <td>${escapeHtml(section.bytes)}</td> | ||
| </tr>`).join("\n ")} | ||
| </tbody> | ||
| </table> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]|null|undefined} rules | ||
| * @returns {string} | ||
| */ | ||
| function renderRules(rules) { | ||
| const rows = Array.isArray(rules) ? rules : []; | ||
| if (rows.length === 0) return ""; | ||
| return `<section id="rules"> | ||
| <h2>Standing Rules</h2> | ||
| <div class="grid"> | ||
| ${rows.map((rule) => `<article class="card"> | ||
| <div class="card-top">${badges([rule.severity, rule.status])}</div> | ||
| <h3>${escapeHtml(itemLabel(rule))}</h3> | ||
| <p>${escapeHtml(rule.description)}</p> | ||
| </article>`).join("\n ")} | ||
| </div> | ||
| </section>`; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} slice | ||
| * @returns {string} | ||
| */ | ||
| export function formatContextSliceHtml(slice) { | ||
| const focus = slice.focus || {}; | ||
| const summary = slice.summary || {}; | ||
| const title = `${focus.kind || "context"} ${itemLabel({ id: focus.id, name: summary.name }) || ""}`.trim(); | ||
| const rawJson = stableStringify(slice); | ||
| return `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>Topogram Context Slice - ${escapeHtml(title)}</title> | ||
| <style> | ||
| :root { color-scheme: light; --bg: #f6f7f9; --panel: #ffffff; --text: #182026; --muted: #627080; --line: #dce3eb; --action: #0f6b5f; --proof: #174ea6; --warn: #8a5a00; } | ||
| * { box-sizing: border-box; } | ||
| body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.45; } | ||
| header { padding: 28px 32px 18px; border-bottom: 1px solid var(--line); background: var(--panel); } | ||
| main { max-width: 1180px; margin: 0 auto; padding: 24px; } | ||
| h1, h2, h3, p { margin-top: 0; } | ||
| h1 { font-size: 28px; margin-bottom: 8px; } | ||
| h2 { font-size: 20px; margin-bottom: 12px; } | ||
| h3 { font-size: 15px; margin-bottom: 8px; } | ||
| code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } | ||
| code { background: #eef3f6; border: 1px solid var(--line); border-radius: 5px; padding: 2px 5px; overflow-wrap: anywhere; } | ||
| pre { overflow: auto; padding: 16px; background: #101820; color: #eef6f3; border-radius: 8px; } | ||
| section { margin-bottom: 24px; } | ||
| table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } | ||
| th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } | ||
| th { font-size: 12px; text-transform: uppercase; letter-spacing: 0; color: var(--muted); background: #f9fbfc; } | ||
| .muted, .id, .source { color: var(--muted); } | ||
| .eyebrow { color: var(--action); font-weight: 700; text-transform: uppercase; font-size: 12px; letter-spacing: 0; margin-bottom: 6px; } | ||
| .hero { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 22px; } | ||
| .hero-side { border-left: 1px solid var(--line); padding-left: 18px; } | ||
| .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; } | ||
| .grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } | ||
| .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; } | ||
| .card-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } | ||
| .badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 8px; margin: 0 5px 5px 0; border-radius: 999px; background: #eef3f6; color: var(--text); border: 1px solid var(--line); font-size: 12px; font-weight: 650; } | ||
| .command-list { padding-left: 18px; margin-bottom: 0; } | ||
| .command-list li { margin: 8px 0; } | ||
| details { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px; } | ||
| summary { cursor: pointer; font-weight: 700; } | ||
| @media (max-width: 760px) { header { padding: 20px; } main { padding: 16px; } .hero { grid-template-columns: 1fr; } .hero-side { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 16px; } } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <header> | ||
| <p class="eyebrow">Topogram Context Slice</p> | ||
| <h1>${escapeHtml(title)}</h1> | ||
| <p class="muted">${escapeHtml(summary.description || summary.status || "Focused agent and human work packet.")}</p> | ||
| </header> | ||
| <main> | ||
| ${renderFrame(slice.frame)} | ||
| ${renderWorkItems(slice.work_items)} | ||
| ${renderImplementationContracts(slice.implementation_contracts)} | ||
| ${renderRelationships(slice.relationships)} | ||
| ${renderProofPlan(slice.proof_plan)} | ||
| ${renderWriteScope(slice.write_scope)} | ||
| ${renderRules(slice.standing_rules)} | ||
| ${renderAttentionBudget(slice.attention_budget)} | ||
| <section id="raw-reference"> | ||
| <h2>Raw Reference</h2> | ||
| <details> | ||
| <summary>Show sanitized slice JSON</summary> | ||
| <pre>${escapeHtml(rawJson)}</pre> | ||
| </details> | ||
| </section> | ||
| </main> | ||
| </body> | ||
| </html> | ||
| `; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @param {string|null|undefined} modeId | ||
| * @returns {string} | ||
| */ | ||
| export function normalizedMode(modeId) { | ||
| if (modeId === "maintained-app-edit") return "maintained-app"; | ||
| if (modeId === "diff-review") return "review"; | ||
| return modeId || "implementation"; | ||
| } | ||
| /** | ||
| * @param {any} scope | ||
| * @returns {string} | ||
| */ | ||
| function widgetScopeSelector(scope = {}) { | ||
| const flags = [ | ||
| scope?.projectionId ? `--surface ${scope.projectionId}` : null, | ||
| scope?.screenId ? `--screen ${scope.screenId}` : null, | ||
| scope?.layoutId ? `--layout ${scope.layoutId}` : null, | ||
| scope?.regionId ? `--region ${scope.regionId}` : null, | ||
| scope?.componentMapId ? `--component-map ${scope.componentMapId}` : null | ||
| ].filter(Boolean); | ||
| return flags.length > 0 ? `${flags.join(" ")} ` : ""; | ||
| } | ||
| /** | ||
| * @param {any} focus | ||
| * @returns {string} | ||
| */ | ||
| function selectorForFocus(focus) { | ||
| if (focus?.kind === "screen") { | ||
| return `${focus.projectionId ? `--surface ${focus.projectionId} ` : ""}--screen ${focus.id || "<id>"}`; | ||
| } | ||
| if (focus?.kind === "widget") { | ||
| return `${widgetScopeSelector(focus.scope)}--widget ${focus.id || "<id>"}`.trim(); | ||
| } | ||
| /** @type {Record<string, string>} */ | ||
| const flagByKind = { | ||
| capability: "--capability", | ||
| workflow: "--workflow", | ||
| screen: "--screen", | ||
| layout: "--layout", | ||
| region: "--region", | ||
| component_map: "--component-map", | ||
| widget: "--widget", | ||
| entity: "--entity", | ||
| journey: "--journey", | ||
| surface: "--surface", | ||
| domain: "--domain", | ||
| feature: "--feature", | ||
| pitch: "--pitch", | ||
| requirement: "--requirement", | ||
| acceptance_criterion: "--acceptance", | ||
| task: "--task", | ||
| plan: "--plan", | ||
| bug: "--bug", | ||
| document: "--document" | ||
| }; | ||
| const flag = flagByKind[focus?.kind] || "--id"; | ||
| const projectionScope = focus?.projectionId && focus.kind !== "surface" ? `--surface ${focus.projectionId} ` : ""; | ||
| return `${projectionScope}${flag} ${focus?.id || "<id>"}`; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {string} | ||
| */ | ||
| function detailFlag(detailLevel) { | ||
| return detailLevel && detailLevel !== "standard" ? ` --detail ${detailLevel}` : ""; | ||
| } | ||
| /** | ||
| * @param {string} mode | ||
| * @param {string} selector | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {string} | ||
| */ | ||
| function sliceQueryCommand(mode, selector, detailLevel) { | ||
| const cliMode = mode === "maintained-app" ? "maintained-app-edit" : mode; | ||
| return `topogram query slice ./topo --mode ${cliMode} ${selector}${detailFlag(detailLevel)} --json`; | ||
| } | ||
| /** | ||
| * @param {string} selector | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {string} | ||
| */ | ||
| function emitSliceCommand(selector, detailLevel) { | ||
| return `topogram emit context-slice ./topo ${selector}${detailFlag(detailLevel)} --json`; | ||
| } | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {number} | ||
| */ | ||
| function itemCount(value) { | ||
| if (Array.isArray(value)) return value.length; | ||
| if (value && typeof value === "object") return Object.keys(/** @type {Record<string, unknown>} */ (value)).length; | ||
| return value == null || value === "" ? 0 : 1; | ||
| } | ||
| /** | ||
| * @param {string} id | ||
| * @param {unknown} value | ||
| * @param {string} nextQuery | ||
| * @returns {any|null} | ||
| */ | ||
| function omittedSection(id, value, nextQuery) { | ||
| const count = itemCount(value); | ||
| if (count === 0) return null; | ||
| return { | ||
| id, | ||
| item_count: count, | ||
| reason: "Omitted from compact implementation mode to keep the packet focused on build contracts and proofs.", | ||
| next_query: nextQuery | ||
| }; | ||
| } | ||
| /** | ||
| * @param {any[]} items | ||
| * @returns {any[]} | ||
| */ | ||
| function compactImplementationWorkItems(items = []) { | ||
| return items | ||
| .filter((item) => item.role === "focus" || item.kind === "acceptance_criterion" || item.kind === "verification" || item.tier === "proof") | ||
| .slice(0, 8); | ||
| } | ||
| /** | ||
| * @param {any[]} relationships | ||
| * @returns {any[]} | ||
| */ | ||
| function compactImplementationRelationships(relationships = []) { | ||
| return relationships | ||
| .filter((relationship) => relationship.tier === "proof" || relationship.relation === "acceptance_ref" || relationship.relation === "satisfies") | ||
| .slice(0, 12); | ||
| } | ||
| /** | ||
| * @param {any} slice | ||
| * @param {string} mode | ||
| * @param {"compact"|"standard"|"full"} detailLevel | ||
| * @returns {any} | ||
| */ | ||
| export function applyModePacketProfile(slice, mode, detailLevel) { | ||
| const leanCompactMode = mode === "implementation" || mode === "maintained-app"; | ||
| if (!leanCompactMode || detailLevel !== "compact") { | ||
| return { | ||
| ...slice, | ||
| packet_profile: { | ||
| mode, | ||
| detail_level: detailLevel, | ||
| profile: "rich_context" | ||
| } | ||
| }; | ||
| } | ||
| const selector = selectorForFocus(slice.focus); | ||
| const standardQuery = sliceQueryCommand(mode, selector, "standard"); | ||
| const fullQuery = sliceQueryCommand(mode, selector, "full"); | ||
| const omitted = [ | ||
| omittedSection("standing_rules", slice.standing_rules, standardQuery), | ||
| omittedSection("related", slice.related, standardQuery), | ||
| omittedSection("relationships", slice.relationships, standardQuery), | ||
| omittedSection("work_items", slice.work_items, standardQuery) | ||
| ].filter(Boolean); | ||
| const agentGuidance = { | ||
| ...(slice.agent_guidance || {}), | ||
| next_queries: [ | ||
| sliceQueryCommand(mode, selector, detailLevel), | ||
| standardQuery, | ||
| fullQuery, | ||
| `topogram query single-agent-plan ./topo --mode ${mode === "maintained-app" ? "maintained-app-edit" : "implementation"} ${selector} --json` | ||
| ], | ||
| warnings: [ | ||
| ...((slice.agent_guidance || {}).warnings || []), | ||
| mode === "maintained-app" | ||
| ? "Compact maintained-app edit mode omits broad context by default; use next_queries when implementation contracts expose a gap." | ||
| : "Compact implementation mode omits broad context by default; use next_queries when implementation contracts expose a gap." | ||
| ] | ||
| }; | ||
| return { | ||
| ...slice, | ||
| packet_profile: { | ||
| mode, | ||
| detail_level: detailLevel, | ||
| profile: "lean_implementation", | ||
| purpose: mode === "maintained-app" | ||
| ? "Edit maintained app code from endpoint, response, seed, write-scope, and proof contracts without embedding broad model context." | ||
| : "Implement from endpoint, response, seed, write-scope, and proof contracts without embedding broad model context." | ||
| }, | ||
| agent_guidance: agentGuidance, | ||
| standing_rules: undefined, | ||
| related: slice.related?.terms ? { terms: slice.related.terms } : {}, | ||
| relationships: compactImplementationRelationships(slice.relationships || []), | ||
| work_items: compactImplementationWorkItems(slice.work_items || []), | ||
| omitted_sections: omitted | ||
| }; | ||
| } | ||
| /** | ||
| * @param {any} slice | ||
| * @param {string|null|undefined} modeId | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {any} | ||
| */ | ||
| export function buildAgentGuidance(slice, modeId, detailLevel = "standard") { | ||
| const mode = normalizedMode(modeId); | ||
| const selector = selectorForFocus(slice.focus); | ||
| const commonCommands = [ | ||
| "topogram check . --json", | ||
| "topogram sdlc check . --strict", | ||
| "topogram sdlc prep commit . --json" | ||
| ]; | ||
| /** @type {Record<string, string[]>} */ | ||
| const modeCommands = { | ||
| modeling: [sliceQueryCommand("modeling", selector, detailLevel)], | ||
| implementation: ["topogram query sdlc-proof-gaps ./topo " + (slice.focus?.kind === "task" ? `--task ${slice.focus.id}` : "--json")], | ||
| review: ["topogram query review-packet ./topo --mode review " + selector + " --json"], | ||
| verification: ["topogram query verification-targets ./topo --mode verification " + selector + " --json"], | ||
| "extract-adopt": ["topogram extract plan . --json", "topogram adopt --list . --json"], | ||
| "maintained-app": [emitSliceCommand(selector, detailLevel)], | ||
| "generated-app": ["topogram generate .", "npm run verify"], | ||
| release: ["topogram release status --strict --json"] | ||
| }; | ||
| const warnings = []; | ||
| if (mode === "maintained-app") { | ||
| warnings.push("Do not overwrite maintained app output with generation; use emitted contracts and focused queries as implementation context."); | ||
| } | ||
| if (mode === "generated-app") { | ||
| warnings.push("Generated-owned outputs may be refreshed by topogram generate; edit the Topogram source first."); | ||
| } | ||
| if (mode === "extract-adopt") { | ||
| warnings.push("Extraction candidates are review-only until explicitly adopted."); | ||
| } | ||
| return { | ||
| mode, | ||
| read_order: mode === "implementation" && detailLevel === "compact" | ||
| ? ["focus", "frame", "summary", "implementation_contracts", "proof_plan", "write_scope", "omitted_sections"] | ||
| : ["focus", "summary", "depends_on", "related", "standing_rules", "verification_targets", "write_scope"], | ||
| next_queries: [ | ||
| sliceQueryCommand(mode, selector, detailLevel), | ||
| `topogram query single-agent-plan ./topo --mode ${mode} ${selector} --json` | ||
| ], | ||
| required_commands: [...(modeCommands[mode] || []), ...commonCommands], | ||
| next_commands: [ | ||
| sliceQueryCommand(mode, selector, detailLevel), | ||
| `topogram query single-agent-plan ./topo --mode ${mode} ${selector} --json`, | ||
| ...(modeCommands[mode] || []), | ||
| ...commonCommands | ||
| ], | ||
| proof_commands: commonCommands, | ||
| completion_command: "topogram sdlc prep commit . --json", | ||
| warnings, | ||
| write_scope_summary: slice.write_scope?.summary || "Edit the canonical Topogram source and project-owned files only; generated-owned outputs should be regenerated." | ||
| }; | ||
| } |
| export function generateContextTaskMode(graph: any, options?: any): any; |
| export function generateDbTarget(target: string, graph: any, options?: any): any; |
| export function generateAppTarget(target: string, graph: any, options?: any): any; |
| // @ts-check | ||
| /** | ||
| * @param {unknown} value | ||
| * @returns {string} | ||
| */ | ||
| export function tsString(value) { | ||
| return JSON.stringify(String(value ?? "")); | ||
| } |
| export function generateNodeHttpApiScaffold(graph: any, options?: any): any; |
| import crypto from "node:crypto"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { generateServerContract } from "./server-contract.js"; | ||
| function sha256(text) { | ||
| return crypto.createHash("sha256").update(String(text)).digest("hex"); | ||
| } | ||
| function textStats(text) { | ||
| const normalized = String(text || ""); | ||
| return { | ||
| bytes: Buffer.byteLength(normalized, "utf8"), | ||
| estimated_tokens: Math.ceil(Array.from(normalized).length / 4), | ||
| sha256: sha256(normalized) | ||
| }; | ||
| } | ||
| function jsString(value) { | ||
| return JSON.stringify(String(value ?? "")); | ||
| } | ||
| function routePathForNode(path) { | ||
| return String(path || "/").replace(/\{([A-Za-z0-9_]+)\}/g, ":$1"); | ||
| } | ||
| function selectContract(graph, options = {}) { | ||
| const contractOrMap = generateServerContract(graph, options); | ||
| if (contractOrMap?.type === "server_contract_graph") return contractOrMap; | ||
| const entries = Object.entries(contractOrMap || {}).sort(([a], [b]) => a.localeCompare(b)); | ||
| if (entries.length === 0) { | ||
| throw new Error("node-http-api-scaffold requires at least one API endpoint contract."); | ||
| } | ||
| return entries[0][1]; | ||
| } | ||
| function routeMatchExpression(route) { | ||
| const path = routePathForNode(route.path); | ||
| if (!path.includes(":")) { | ||
| return { | ||
| setup: "", | ||
| condition: `req.method === ${jsString(String(route.method || "GET").toUpperCase())} && url.pathname === ${jsString(path)}`, | ||
| paramsExpression: "{}" | ||
| }; | ||
| } | ||
| const varName = `match_${String(route.endpoint?.id || route.capabilityId).replace(/[^A-Za-z0-9_]/g, "_")}`; | ||
| return { | ||
| setup: ` const ${varName} = matchPath(${jsString(path)}, url.pathname);\n`, | ||
| condition: `req.method === ${jsString(String(route.method || "GET").toUpperCase())} && ${varName}`, | ||
| paramsExpression: varName | ||
| }; | ||
| } | ||
| function endpointId(route, index) { | ||
| return route.endpoint?.id || `endpoint_${index + 1}`; | ||
| } | ||
| function responseContainer(route) { | ||
| return route.endpoint?.responseContainer || route.responseContract?.mode || null; | ||
| } | ||
| function refId(value) { | ||
| if (!value) return null; | ||
| if (typeof value === "string") return value; | ||
| return value.id ? String(value.id) : null; | ||
| } | ||
| function responseEntityId(route) { | ||
| return refId(route.endpoint?.responseEntity) | ||
| || refId(route.responseContract?.entity) | ||
| || refId(route.responseContract?.entity_id) | ||
| || null; | ||
| } | ||
| function responseResult(route) { | ||
| return route.endpoint?.responseResult || route.responseContract?.result || null; | ||
| } | ||
| function responseMode(route) { | ||
| const container = responseContainer(route); | ||
| if (container === "json_array") return "collection"; | ||
| const result = responseResult(route); | ||
| if (result === "collection") return "collection"; | ||
| if (result === "none" || container === "none") return "none"; | ||
| return "item"; | ||
| } | ||
| function routeSeedKeyCandidates(route, entityId) { | ||
| const values = [ | ||
| endpointId(route, 0), | ||
| route.capabilityId, | ||
| entityId, | ||
| route.path | ||
| ]; | ||
| const candidates = []; | ||
| for (const value of values) { | ||
| const normalized = String(value || "") | ||
| .replace(/^endpoint_(list|get|view|search|create|update|delete|record)_/, "") | ||
| .replace(/^cap_(list|get|view|search|create|update|delete|record|apply)_/, "") | ||
| .replace(/^entity_/, "") | ||
| .replace(/^\/api\//, "") | ||
| .replace(/[:/{}.-]+/g, "_") | ||
| .replace(/_id$/, "") | ||
| .replace(/_entry$/, "") | ||
| .replace(/_item$/, "") | ||
| .replace(/^_+|_+$/g, ""); | ||
| if (!normalized) continue; | ||
| const variants = [ | ||
| normalized, | ||
| normalized.endsWith("s") ? normalized : `${normalized}s`, | ||
| normalized.endsWith("y") ? `${normalized.slice(0, -1)}ies` : null | ||
| ].filter(Boolean); | ||
| for (const candidate of variants) { | ||
| if (!candidates.includes(candidate)) candidates.push(candidate); | ||
| } | ||
| } | ||
| return candidates; | ||
| } | ||
| function isSeedBackedRead(route) { | ||
| return String(route.method || "GET").toUpperCase() === "GET" | ||
| && responseMode(route) !== "none" | ||
| && Boolean(responseEntityId(route)); | ||
| } | ||
| function renderSeedBackedEndpointBody(route, index, paramsExpression) { | ||
| const id = endpointId(route, index); | ||
| const entityId = responseEntityId(route); | ||
| const keys = routeSeedKeyCandidates(route, entityId); | ||
| const mode = responseMode(route); | ||
| if (mode === "collection") { | ||
| return ` return sendJson(res, ${route.successStatus || 200}, seedCollection(${jsString(entityId)}, ${JSON.stringify(keys)}));`; | ||
| } | ||
| return ` const seeded = seedSingle(${jsString(entityId)}, ${JSON.stringify(keys)}, ${paramsExpression}); | ||
| if (!seeded) return sendJson(res, 404, { error: "not_found", endpoint: ${jsString(id)} }); | ||
| return sendJson(res, ${route.successStatus || 200}, seeded);`; | ||
| } | ||
| function modelSeedData(graph) { | ||
| const output = {}; | ||
| for (const seed of graph.byKind?.seed_data || []) { | ||
| const entityId = seed.entity?.id || null; | ||
| if (!entityId) continue; | ||
| output[entityId] = [ | ||
| ...(output[entityId] || []), | ||
| ...(seed.records || []).map((record) => record.fields || {}) | ||
| ]; | ||
| } | ||
| return output; | ||
| } | ||
| function renderEndpointBlock(route, index) { | ||
| const id = endpointId(route, index); | ||
| const capabilityId = route.capabilityId || ""; | ||
| const marker = `topogram:endpoint ${id}`; | ||
| const successStatus = route.successStatus || 200; | ||
| const { setup, condition, paramsExpression } = routeMatchExpression(route); | ||
| const body = isSeedBackedRead(route) | ||
| ? renderSeedBackedEndpointBody(route, index, paramsExpression) | ||
| : ` // TODO: implement ${capabilityId} | ||
| return sendJson(res, 501, { | ||
| error: "not_implemented", | ||
| endpoint: ${jsString(id)}, | ||
| capability: ${jsString(capabilityId)}, | ||
| expected_status: ${successStatus} | ||
| });`; | ||
| return `${setup} if (${condition}) { | ||
| // ${marker} start | ||
| ${body} | ||
| // ${marker} end | ||
| }`; | ||
| } | ||
| function renderServer(contract, options = {}) { | ||
| const seedFile = options.seedFile || "seed-fixture.json"; | ||
| const modelSeeds = options.modelSeeds || {}; | ||
| const routes = contract.routes || []; | ||
| const routeBlocks = routes.map(renderEndpointBlock).join("\n\n"); | ||
| return `import http from "node:http"; | ||
| import { readFileSync } from "node:fs"; | ||
| const port = Number(process.env.PORT || 3000); | ||
| const seed = JSON.parse(readFileSync(new URL(${jsString(`./${seedFile}`)}, import.meta.url), "utf8")); | ||
| const modelSeeds = ${JSON.stringify(modelSeeds, null, 2)}; | ||
| function sendJson(res, status, body) { | ||
| res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); | ||
| res.end(JSON.stringify(body)); | ||
| } | ||
| function sendHtml(res, status, html) { | ||
| res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); | ||
| res.end(html); | ||
| } | ||
| function readJsonBody(req) { | ||
| return new Promise((resolve) => { | ||
| let raw = ""; | ||
| req.on("data", (chunk) => { raw += chunk; }); | ||
| req.on("end", () => { | ||
| try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } | ||
| }); | ||
| req.on("error", () => resolve({})); | ||
| }); | ||
| } | ||
| function matchPath(pattern, pathname) { | ||
| const patternParts = pattern.split("/").filter(Boolean); | ||
| const pathParts = pathname.split("/").filter(Boolean); | ||
| if (patternParts.length !== pathParts.length) return null; | ||
| const params = {}; | ||
| for (let index = 0; index < patternParts.length; index += 1) { | ||
| const part = patternParts[index]; | ||
| if (part.startsWith(":")) { | ||
| params[part.slice(1)] = decodeURIComponent(pathParts[index]); | ||
| } else if (part !== pathParts[index]) { | ||
| return null; | ||
| } | ||
| } | ||
| return params; | ||
| } | ||
| function seedAreas() { | ||
| return [ | ||
| seed, | ||
| seed.base, | ||
| ...Object.keys(seed) | ||
| .filter((key) => /^wave_\\d+$/.test(key)) | ||
| .sort() | ||
| .map((key) => seed[key]) | ||
| ].filter(Boolean); | ||
| } | ||
| function fixtureValue(keys) { | ||
| for (const area of seedAreas()) { | ||
| for (const key of keys || []) { | ||
| if (Object.prototype.hasOwnProperty.call(area, key)) return area[key]; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function seedCollection(entityId, keys) { | ||
| const value = fixtureValue(keys); | ||
| if (Array.isArray(value)) return value; | ||
| const records = modelSeeds[entityId]; | ||
| return Array.isArray(records) ? records : []; | ||
| } | ||
| function seedSingle(entityId, keys, params = {}) { | ||
| const value = fixtureValue(keys); | ||
| if (value && !Array.isArray(value) && typeof value === "object") return value; | ||
| const records = Array.isArray(value) ? value : seedCollection(entityId, keys); | ||
| const wanted = Object.values(params || {}).filter(Boolean).map(String); | ||
| if (wanted.length === 0) return records[0] || null; | ||
| return records.find((record) => | ||
| wanted.includes(String(record.id || "")) | ||
| || wanted.some((param) => Object.values(record).map(String).includes(param)) | ||
| ) || null; | ||
| } | ||
| function renderDashboard() { | ||
| // topogram:custom dashboard start | ||
| return \`<!doctype html><html><head><meta charset="utf-8"><title>Clinic Ops</title></head> | ||
| <body><main> | ||
| <h1>Clinic Ops Dashboard</h1> | ||
| <section><h2>Today's appointments</h2><p>Implement dashboard panels for the current feature wave.</p></section> | ||
| </main></body></html>\`; | ||
| // topogram:custom dashboard end | ||
| } | ||
| // topogram:custom helpers start | ||
| // Add local helper functions here. This region is preserved by experiment scaffold regeneration. | ||
| // topogram:custom helpers end | ||
| const server = http.createServer(async (req, res) => { | ||
| const url = new URL(req.url || "/", "http://127.0.0.1"); | ||
| if (req.method === "GET" && url.pathname === "/") return sendHtml(res, 200, renderDashboard()); | ||
| if (req.method === "GET" && url.pathname === "/health") return sendJson(res, 200, { ok: true }); | ||
| if (req.method === "GET" && url.pathname === "/ready") return sendJson(res, 200, { ok: true, ready: true }); | ||
| ${routeBlocks} | ||
| return sendJson(res, 404, { error: "not_found", path: url.pathname }); | ||
| }); | ||
| server.listen(port, "127.0.0.1", () => { | ||
| console.log("topogram node-http-api scaffold listening on " + port); | ||
| }); | ||
| `; | ||
| } | ||
| function existingServerSource(options = {}) { | ||
| const projectRoot = options.projectRoot || options.configDir || null; | ||
| if (!projectRoot) return null; | ||
| const serverPath = path.join(projectRoot, "server.mjs"); | ||
| if (!fs.existsSync(serverPath)) return null; | ||
| const stat = fs.statSync(serverPath); | ||
| if (!stat.isFile() || stat.size > 512 * 1024) return null; | ||
| return fs.readFileSync(serverPath, "utf8"); | ||
| } | ||
| function patchPlanFor(endpointRows, options = {}) { | ||
| const existingSource = existingServerSource(options); | ||
| return endpointRows.map((endpoint) => { | ||
| const markerPresent = existingSource ? existingSource.includes(endpoint.marker) : false; | ||
| const patchReady = endpoint.scaffold_behavior === "todo" | ||
| ? todoPatchReadyForEndpoint(endpoint, markerPresent) | ||
| : null; | ||
| return { | ||
| endpoint_id: endpoint.endpoint_id, | ||
| method: endpoint.method, | ||
| path: endpoint.path, | ||
| marker: endpoint.marker, | ||
| status: markerPresent ? "present" : "missing", | ||
| action: markerPresent ? "preserve_or_update_marked_region" : "add_handler_region", | ||
| insertion_anchor: markerPresent ? endpoint.marker : "server route handler before 404 fallback", | ||
| behavior: endpoint.scaffold_behavior, | ||
| patch_ready: patchReady | ||
| }; | ||
| }); | ||
| } | ||
| function todoPatchReadyForEndpoint(endpoint, markerPresent) { | ||
| const status = endpoint.success_status || 200; | ||
| const fallbackId = `${String(endpoint.endpoint_id || "endpoint").replace(/^endpoint_/, "")}_created`; | ||
| const body = String(endpoint.method || "GET").toUpperCase() === "POST" | ||
| ? ` const body = await readJsonBody(req); | ||
| return sendJson(res, ${status}, { | ||
| id: body.id || ${jsString(fallbackId)}, | ||
| status: body.status || "recorded", | ||
| ...body | ||
| });` | ||
| : ` return sendJson(res, ${status}, { ok: true, endpoint: ${jsString(endpoint.endpoint_id)} });`; | ||
| const replacement = ` // ${endpoint.marker} start | ||
| ${body} | ||
| // ${endpoint.marker} end`; | ||
| const search = ` // ${endpoint.marker} start | ||
| // TODO: implement ${endpoint.capability_id || ""} | ||
| return sendJson(res, 501, { | ||
| error: "not_implemented", | ||
| endpoint: ${jsString(endpoint.endpoint_id)}, | ||
| capability: ${jsString(endpoint.capability_id || "")}, | ||
| expected_status: ${status} | ||
| }); | ||
| // ${endpoint.marker} end`; | ||
| return { | ||
| tool: "replace_file_text", | ||
| args: markerPresent ? { | ||
| path: "server.mjs", | ||
| search, | ||
| replacement | ||
| } : null, | ||
| mode: markerPresent ? "replace_todo_marker_region" : "manual_patch_required", | ||
| generated_code: replacement, | ||
| note: markerPresent | ||
| ? "Apply this exact replacement for the generated TODO endpoint, then run the public check." | ||
| : "Add this endpoint behavior to the generated marker region, then run the public check." | ||
| }; | ||
| } | ||
| function manifestFor(contract, serverSource, options = {}) { | ||
| const seedFile = options.seedFile || "seed-fixture.json"; | ||
| const endpointRows = (contract.routes || []).map((route, index) => ({ | ||
| endpoint_id: endpointId(route, index), | ||
| capability_id: route.capabilityId || null, | ||
| method: String(route.method || "GET").toUpperCase(), | ||
| path: route.path || "/", | ||
| success_status: route.successStatus || 200, | ||
| auth: route.endpoint?.auth || "none", | ||
| response_container: responseContainer(route), | ||
| response_entity: responseEntityId(route), | ||
| scaffold_behavior: isSeedBackedRead(route) ? "seed_backed_read" : "todo", | ||
| seed_keys: isSeedBackedRead(route) ? routeSeedKeyCandidates(route, responseEntityId(route)) : [], | ||
| marker: `topogram:endpoint ${endpointId(route, index)}` | ||
| })); | ||
| return { | ||
| type: "node_http_api_scaffold_manifest", | ||
| version: 1, | ||
| source_contract: { | ||
| target: "server-contract", | ||
| surface: contract.surface || null, | ||
| route_count: endpointRows.length | ||
| }, | ||
| seed_file: seedFile, | ||
| endpoints: endpointRows, | ||
| patch_plan: patchPlanFor(endpointRows, options), | ||
| markers: endpointRows.map((endpoint) => endpoint.marker), | ||
| seed_backed_read_count: endpointRows.filter((endpoint) => endpoint.scaffold_behavior === "seed_backed_read").length, | ||
| todo_count: endpointRows.filter((endpoint) => endpoint.scaffold_behavior === "todo").length, | ||
| files: [ | ||
| { | ||
| path: "server.mjs", | ||
| ...textStats(serverSource) | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| export function generateNodeHttpApiScaffold(graph, options = {}) { | ||
| const contract = selectContract(graph, options); | ||
| const serverSource = renderServer(contract, { ...options, modelSeeds: modelSeedData(graph) }); | ||
| const manifest = manifestFor(contract, serverSource, options); | ||
| const manifestSource = `${JSON.stringify(manifest, null, 2)}\n`; | ||
| manifest.files.push({ | ||
| path: "topogram-scaffold-manifest.json", | ||
| ...textStats(manifestSource) | ||
| }); | ||
| return { | ||
| "server.mjs": serverSource, | ||
| "topogram-scaffold-manifest.json": `${JSON.stringify(manifest, null, 2)}\n` | ||
| }; | ||
| } |
| export function generateUiWidgetContract(graph: any, options?: any): any; |
| // @ts-check | ||
| /** @type {Record<string, string>} */ | ||
| const DOMAIN_MEMBER_BUCKETS = { | ||
| term: "terms", | ||
| capability: "capabilities", | ||
| seed_data: "seedData", | ||
| theme: "themes", | ||
| entity: "entities", | ||
| rule: "rules", | ||
| verification: "verifications", | ||
| section: "sections", | ||
| navpoint: "navpoints", | ||
| endpoint: "endpoints", | ||
| screen: "screens", | ||
| region: "regions", | ||
| layout: "layouts", | ||
| design_language: "designLanguages", | ||
| component_map: "componentMaps", | ||
| surface: "surfaces", | ||
| decision: "decisions", | ||
| journey: "journeys", | ||
| workflow: "workflows", | ||
| feature: "features", | ||
| pitch: "pitches", | ||
| requirement: "requirements", | ||
| task: "tasks", | ||
| plan: "plans", | ||
| bug: "bugs" | ||
| }; | ||
| export function emptyDomainMembers() { | ||
| return { | ||
| terms: [], | ||
| capabilities: [], | ||
| seedData: [], | ||
| themes: [], | ||
| entities: [], | ||
| rules: [], | ||
| verifications: [], | ||
| sections: [], | ||
| navpoints: [], | ||
| endpoints: [], | ||
| screens: [], | ||
| regions: [], | ||
| layouts: [], | ||
| designLanguages: [], | ||
| componentMaps: [], | ||
| surfaces: [], | ||
| decisions: [], | ||
| journeys: [], | ||
| workflows: [], | ||
| features: [], | ||
| pitches: [], | ||
| requirements: [], | ||
| tasks: [], | ||
| plans: [], | ||
| bugs: [], | ||
| documents: [] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {Array<Record<string, any>>} resolvedStatements | ||
| * @param {Array<Record<string, any>>} docs | ||
| * @returns {Map<string, Record<string, string[]>>} | ||
| */ | ||
| export function buildDomainMembersById(resolvedStatements, docs = []) { | ||
| const domainMembersById = new Map(); | ||
| for (const statement of resolvedStatements) { | ||
| if (statement.kind === "domain") domainMembersById.set(statement.id, emptyDomainMembers()); | ||
| } | ||
| for (const statement of resolvedStatements) { | ||
| const bucketKey = DOMAIN_MEMBER_BUCKETS[statement.kind]; | ||
| if (!bucketKey || !statement.resolvedDomain) continue; | ||
| const members = domainMembersById.get(statement.resolvedDomain.id); | ||
| if (members) members[bucketKey].push(statement.id); | ||
| } | ||
| for (const doc of docs || []) { | ||
| if (doc.parseError || !doc.metadata?.domain || !doc.metadata.id) continue; | ||
| const members = domainMembersById.get(doc.metadata.domain); | ||
| if (members) members.documents.push(doc.metadata.id); | ||
| } | ||
| for (const members of domainMembersById.values()) { | ||
| for (const bucket of Object.values(members)) bucket.sort(); | ||
| } | ||
| return domainMembersById; | ||
| } |
| // @ts-check | ||
| export const APPROX_CHARS_PER_TOKEN = 4; | ||
| /** | ||
| * @param {string} text | ||
| * @returns {{ bytes: number, estimated_tokens: number }} | ||
| */ | ||
| export function textTokenStats(text) { | ||
| const normalized = String(text || ""); | ||
| return { | ||
| bytes: Buffer.byteLength(normalized, "utf8"), | ||
| estimated_tokens: Math.ceil(Array.from(normalized).length / APPROX_CHARS_PER_TOKEN) | ||
| }; | ||
| } |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { eventLogSummary, observedToolEvents } from "./events.js"; | ||
| import { futureEndpointWriteEvidence } from "./future-wave-smells.js"; | ||
| import { implementerAttentionSmells } from "./implementer-smells.js"; | ||
| import { qualityScoreSmells } from "./quality-smells.js"; | ||
| import { recommendations } from "./recommendations.js"; | ||
| export { formatExperimentLessonDraft, formatTraceMarkdown } from "./format.js"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| const TOKEN_DELTA_FACTOR = 4; | ||
| const TOKEN_DELTA_MIN = 20000; | ||
| const READ_FILE_COUNT_LIMIT = 6; | ||
| const READ_FILE_TOKEN_LIMIT = 8000; | ||
| /** | ||
| * @param {string} filePath | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function readJsonIfExists(filePath) { | ||
| if (!fs.existsSync(filePath)) return null; | ||
| return JSON.parse(fs.readFileSync(filePath, "utf8")); | ||
| } | ||
| /** | ||
| * @param {string} filePath | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function readJsonlIfExists(filePath) { | ||
| if (!fs.existsSync(filePath)) return []; | ||
| return fs.readFileSync(filePath, "utf8") | ||
| .split(/\r?\n/) | ||
| .filter(/** @param {string} line */ (line) => line.trim()) | ||
| .map(/** @param {string} line */ (line) => { | ||
| try { | ||
| return JSON.parse(line); | ||
| } catch { | ||
| return { malformed: true }; | ||
| } | ||
| }); | ||
| } | ||
| /** | ||
| * @param {string} base | ||
| * @param {string} target | ||
| * @returns {string} | ||
| */ | ||
| function portablePath(base, target) { | ||
| return path.relative(base, target).split(path.sep).join("/") || "."; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} entry | ||
| * @returns {number} | ||
| */ | ||
| function usageTotal(entry) { | ||
| const usage = entry.usage || {}; | ||
| return Number(usage.total_tokens ?? usage.total ?? 0); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} entry | ||
| * @returns {number} | ||
| */ | ||
| function inputTokens(entry) { | ||
| const usage = entry.usage || {}; | ||
| return Number(usage.input_tokens ?? usage.prompt_tokens ?? 0); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} entry | ||
| * @returns {number} | ||
| */ | ||
| function outputTokens(entry) { | ||
| const usage = entry.usage || {}; | ||
| return Number(usage.output_tokens ?? usage.completion_tokens ?? 0); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} entries | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function tokenTotals(entries) { | ||
| return { | ||
| total_tokens: entries.reduce((sum, entry) => sum + usageTotal(entry), 0), | ||
| input_tokens: entries.reduce((sum, entry) => sum + inputTokens(entry), 0), | ||
| output_tokens: entries.reduce((sum, entry) => sum + outputTokens(entry), 0), | ||
| model_calls: entries.filter((entry) => entry.usage && !["provider_retry", "provider_error", "provider_response_problem"].includes(String(entry.phase || ""))).length | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} events | ||
| * @returns {Record<string, number>} | ||
| */ | ||
| function toolCounts(events) { | ||
| /** @type {Record<string, number>} */ | ||
| const counts = {}; | ||
| for (const event of events) { | ||
| const tool = String(event.tool || ""); | ||
| if (!tool) continue; | ||
| counts[tool] = (counts[tool] || 0) + 1; | ||
| if (tool === "run_topogram" && event.args?.command) { | ||
| const key = `run_topogram:${event.args.command}`; | ||
| counts[key] = (counts[key] || 0) + 1; | ||
| } | ||
| } | ||
| return counts; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} results | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @returns {Record<string, number>} | ||
| */ | ||
| function aggregateToolCounts(results, eventLog = []) { | ||
| /** @type {Record<string, number>} */ | ||
| const counts = {}; | ||
| for (const result of results) { | ||
| for (const [tool, count] of Object.entries(toolCounts(observedToolEvents(result, eventLog)))) { | ||
| counts[tool] = (counts[tool] || 0) + Number(count); | ||
| } | ||
| } | ||
| return counts; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} usageLog | ||
| * @param {AnyRecord} result | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function usageForResult(usageLog, result) { | ||
| return usageLog.filter((entry) => | ||
| entry.trial === result.trial | ||
| && entry.arm === result.arm | ||
| && entry.wave === result.wave | ||
| ); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} result | ||
| * @returns {number} | ||
| */ | ||
| function packetTokens(result) { | ||
| const metrics = result.topogram_proof_metrics || {}; | ||
| return Number(metrics.agent_packet_estimated_tokens || metrics.work_next_agent_packet_estimated_tokens || 0); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} results | ||
| * @returns {number} | ||
| */ | ||
| function packetTokenSum(results) { | ||
| return results.reduce((sum, result) => sum + packetTokens(result), 0); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} result | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @returns {number} | ||
| */ | ||
| function publicChecksFromActions(result, eventLog = []) { | ||
| const metricsCount = Number(result.topogram_proof_metrics?.public_checks_from_actions || 0); | ||
| if (metricsCount > 0) return metricsCount; | ||
| return observedToolEvents(result, eventLog).filter((event) => | ||
| (event.tool === "apply_work_next_action" && event.result?.public_check_ok) | ||
| || (event.tool === "run_topogram" && event.args?.command === "work_advance" && event.result?.public_check_ok) | ||
| ).length; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} result | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @returns {number} | ||
| */ | ||
| function scaffoldRunsFromActions(result, eventLog = []) { | ||
| const metrics = result.topogram_proof_metrics || {}; | ||
| const actionCount = observedToolEvents(result, eventLog).filter((event) => | ||
| event.tool === "apply_work_next_action" && event.result?.action_kind === "scaffold_patch_and_check" | ||
| ).length; | ||
| const proofStateGenerated = metrics.final_proof_state?.scaffold_generated_this_wave ? 1 : 0; | ||
| return Math.max(actionCount, proofStateGenerated); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} result | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @returns {boolean} | ||
| */ | ||
| function publicCheckSatisfied(result, eventLog = []) { | ||
| const events = observedToolEvents(result, eventLog); | ||
| return events.some((event) => event.tool === "run_public_check" && event.result?.ok !== false) | ||
| || publicChecksFromActions(result, eventLog) > 0; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} results | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function observedWaves(results, eventLog = []) { | ||
| return results.map((result) => ({ | ||
| trial: result.trial, | ||
| arm: result.arm, | ||
| wave: result.wave, | ||
| status: result.status || "unknown", | ||
| pass_rate: Number(result.evaluation?.pass_rate || 0), | ||
| public_check_ran: publicCheckSatisfied(result, eventLog), | ||
| public_checks_from_actions: publicChecksFromActions(result, eventLog), | ||
| work_next_states: result.topogram_proof_metrics?.state_sequence || result.topogram_proof_metrics?.work_next_state_sequence || [], | ||
| tool_counts: toolCounts(observedToolEvents(result, eventLog)), | ||
| agent_packet_estimated_tokens: packetTokens(result) | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} manifest | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function evaluationContext(manifest) { | ||
| const hasContext = Boolean(manifest?.evaluation_context && typeof manifest.evaluation_context === "object"); | ||
| const context = hasContext ? (manifest?.evaluation_context || {}) : {}; | ||
| return { | ||
| claim: context.claim || null, | ||
| scenario_id: context.scenario_id || null, | ||
| evaluator_profile: context.evaluator_profile || manifest?.evaluator_profile || null, | ||
| evaluators_run: Array.isArray(context.evaluators_run) | ||
| ? context.evaluators_run | ||
| : (Array.isArray(manifest?.evaluators_run) ? manifest.evaluators_run : []), | ||
| suite_id: context.suite_id || manifest?.experiment_id || null, | ||
| run_class: context.run_class || manifest?.run_mode || null, | ||
| topogram_mode: context.topogram_mode || null, | ||
| score_dimensions: Array.isArray(context.score_dimensions) ? context.score_dimensions : [], | ||
| primary_success_metric: context.primary_success_metric || null, | ||
| secondary_metrics: Array.isArray(context.secondary_metrics) ? context.secondary_metrics : [], | ||
| not_scored: Array.isArray(context.not_scored) | ||
| ? context.not_scored | ||
| : (hasContext ? [] : ["evaluation_context_missing"]) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} context | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function matrixPosition(context) { | ||
| return { | ||
| suite_id: context.suite_id || null, | ||
| scenario_id: context.scenario_id || null, | ||
| evaluator_profile: context.evaluator_profile || null, | ||
| run_class: context.run_class || null, | ||
| topogram_mode: context.topogram_mode || null, | ||
| claim: context.claim || null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} context | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function evidenceGaps(context) { | ||
| return (context.not_scored || []).map(/** @param {string} dimension */ (dimension) => ({ | ||
| dimension, | ||
| reason: "This run did not include a scorer or proof target for this dimension." | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} outcome | ||
| * @param {AnyRecord[]} gaps | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function publicationReadiness(outcome, gaps) { | ||
| if (!outcome.all_waves_passed) { | ||
| return { | ||
| status: "not_publishable", | ||
| reasons: ["One or more waves failed or did not complete."] | ||
| }; | ||
| } | ||
| if (outcome.run_mode !== "paired_comparison") { | ||
| return { | ||
| status: "stabilization_only", | ||
| reasons: ["This run is not a paired comparison."] | ||
| }; | ||
| } | ||
| if (gaps.length > 0) { | ||
| return { | ||
| status: "claim_limited_by_evidence_gaps", | ||
| reasons: gaps.map((gap) => `${gap.dimension} was not scored.`) | ||
| }; | ||
| } | ||
| return { | ||
| status: "benchmark_candidate", | ||
| reasons: ["All waves passed in a paired comparison with no declared evidence gaps."] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} manifest | ||
| * @param {AnyRecord|null} auditBundle | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function expectedWorkflow(manifest, auditBundle) { | ||
| const context = evaluationContext(manifest); | ||
| const auditBundleSummary = auditBundle | ||
| ? { | ||
| profile: auditBundle.profile || null, | ||
| focus: auditBundle.focus || null, | ||
| selector: auditBundle.selector || null, | ||
| bundle_count: auditBundle.bundle_count || (Array.isArray(auditBundle.bundles) ? auditBundle.bundles.length : 1), | ||
| artifacts_count: Array.isArray(auditBundle.artifacts) | ||
| ? auditBundle.artifacts.length | ||
| : Number(auditBundle.artifacts_count || 0), | ||
| bundles: Array.isArray(auditBundle.bundles) ? auditBundle.bundles : [] | ||
| } | ||
| : null; | ||
| return { | ||
| source: auditBundle ? "audit_bundle_and_run_manifest" : "run_manifest", | ||
| experiment_id: manifest?.experiment_id || null, | ||
| run_id: manifest?.run_id || null, | ||
| run_mode: manifest?.run_mode || null, | ||
| evaluation_context: context, | ||
| scenario_id: context.scenario_id, | ||
| topogram_mode: context.topogram_mode, | ||
| waves: Array.isArray(manifest?.waves) ? manifest.waves : [], | ||
| audit_bundle: auditBundleSummary, | ||
| expected_commands: [ | ||
| "topogram work next ./topo --task <task-id> --mode implementation --json", | ||
| "topogram check . --json", | ||
| "topogram emit audit-bundle ./topo --task <task-id> --profile experiment --write --out-dir ./artifacts" | ||
| ] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} manifest | ||
| * @param {AnyRecord[]} results | ||
| * @param {AnyRecord[]} usageLog | ||
| * @param {AnyRecord[]} eventLog | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function observedWorkflow(manifest, results, usageLog, eventLog = []) { | ||
| const arms = Array.isArray(manifest?.arms_run) && manifest.arms_run.length > 0 | ||
| ? manifest.arms_run | ||
| : [...new Set(results.map((result) => result.arm).filter(Boolean))]; | ||
| /** @type {AnyRecord} */ | ||
| const byArm = {}; | ||
| for (const arm of arms) { | ||
| const armResults = results.filter((result) => result.arm === arm); | ||
| byArm[arm] = { | ||
| waves: armResults.length, | ||
| tokens: tokenTotals(usageLog.filter((entry) => entry.arm === arm)), | ||
| tool_calls: armResults.reduce((sum, result) => sum + Number(result.tool_calls || 0), 0), | ||
| tool_counts: aggregateToolCounts(armResults, eventLog), | ||
| agent_packet_estimated_tokens: packetTokenSum(armResults) | ||
| }; | ||
| } | ||
| return { | ||
| arms, | ||
| by_arm: byArm, | ||
| event_log: eventLogSummary(eventLog), | ||
| waves: observedWaves(results, eventLog) | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} type | ||
| * @param {AnyRecord} details | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function smell(type, details) { | ||
| return { | ||
| type, | ||
| severity: details.severity || "medium", | ||
| category: details.category || "packet", | ||
| message: details.message, | ||
| evidence: details.evidence || {} | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} results | ||
| * @param {AnyRecord[]} usageLog | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @param {string|null} [runRoot] | ||
| * @param {AnyRecord|null} [manifest] | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function attentionSmells(results, usageLog, eventLog = [], runRoot = null, manifest = null) { | ||
| /** @type {AnyRecord[]} */ | ||
| const smells = []; | ||
| for (const result of results) { | ||
| const states = result.topogram_proof_metrics?.state_sequence || result.topogram_proof_metrics?.work_next_state_sequence || []; | ||
| for (let index = 1; index < states.length; index += 1) { | ||
| if (states[index] === states[index - 1]) { | ||
| smells.push(smell("repeated_work_state", { | ||
| category: "workflow", | ||
| message: `Repeated work state '${states[index]}' in ${result.arm}/${result.wave}.`, | ||
| evidence: { arm: result.arm, wave: result.wave, state: states[index] } | ||
| })); | ||
| } | ||
| } | ||
| const events = observedToolEvents(result, eventLog); | ||
| const reads = events.filter((event) => event.tool === "read_file"); | ||
| const readTokens = reads.reduce((sum, event) => sum + Number(event.result?.model_payload_estimated_tokens || 0), 0); | ||
| if (reads.length > READ_FILE_COUNT_LIMIT || readTokens > READ_FILE_TOKEN_LIMIT) { | ||
| smells.push(smell("excessive_file_reads", { | ||
| category: "packet", | ||
| message: `${result.arm}/${result.wave} read ${reads.length} files (${readTokens} estimated payload tokens).`, | ||
| evidence: { arm: result.arm, wave: result.wave, read_file_calls: reads.length, estimated_tokens: readTokens } | ||
| })); | ||
| } | ||
| const actualTokens = tokenTotals(usageForResult(usageLog, result)).total_tokens; | ||
| const estimatedPacketTokens = packetTokens(result); | ||
| if (estimatedPacketTokens > 0 && actualTokens > Math.max(TOKEN_DELTA_MIN, estimatedPacketTokens * TOKEN_DELTA_FACTOR)) { | ||
| smells.push(smell("large_actual_vs_packet_token_delta", { | ||
| category: "packet", | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} used ${actualTokens} actual tokens against ${estimatedPacketTokens} estimated packet tokens.`, | ||
| evidence: { arm: result.arm, wave: result.wave, actual_tokens: actualTokens, packet_estimated_tokens: estimatedPacketTokens } | ||
| })); | ||
| } | ||
| if (result.status === "completed" && events.length > 0 && !publicCheckSatisfied(result, eventLog)) { | ||
| smells.push(smell("proof_skipped", { | ||
| category: "proof", | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} completed without a public check tool call.`, | ||
| evidence: { arm: result.arm, wave: result.wave } | ||
| })); | ||
| } | ||
| const futureWrites = runRoot ? futureEndpointWriteEvidence(runRoot, manifest, result) : []; | ||
| if (futureWrites.length > 0) { | ||
| smells.push(smell("future_wave_endpoint_implemented_early", { | ||
| category: "evaluation_design", | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} wrote handlers for endpoints from later waves.`, | ||
| evidence: { arm: result.arm, wave: result.wave, writes: futureWrites } | ||
| })); | ||
| } | ||
| let codeReady = false; | ||
| let proofComplete = false; | ||
| for (const event of events) { | ||
| if (event.tool === "run_topogram" && event.args?.command === "work_next" && event.result?.state === "code_edit_ready") { | ||
| codeReady = true; | ||
| } | ||
| if (event.tool === "run_topogram" && event.args?.command === "work_advance" && event.result?.state === "code_edit_ready") { | ||
| codeReady = true; | ||
| } | ||
| if (event.tool === "run_topogram" && event.args?.command === "work_advance" && event.result?.public_check_ok) { | ||
| proofComplete = true; | ||
| } | ||
| if (event.tool === "apply_work_next_action" && event.result?.public_check_ok) { | ||
| proofComplete = true; | ||
| } | ||
| if (event.tool === "run_public_check" && event.result?.ok !== false) { | ||
| proofComplete = true; | ||
| } | ||
| const writesApp = ["write_file", "replace_file_text"].includes(String(event.tool || "")) | ||
| && !String(event.args?.path || "").startsWith("topo/"); | ||
| if (writesApp && result.arm === "topogram" && proofComplete) { | ||
| smells.push(smell("app_edit_after_proof_complete", { | ||
| category: "workflow", | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} edited app files after public proof had already passed.`, | ||
| evidence: { arm: result.arm, wave: result.wave, path: event.args?.path || null } | ||
| })); | ||
| } else if (writesApp && result.arm === "topogram" && !codeReady) { | ||
| smells.push(smell("app_edit_before_code_ready", { | ||
| category: "workflow", | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} edited app files before work next reached code_edit_ready.`, | ||
| evidence: { arm: result.arm, wave: result.wave, path: event.args?.path || null } | ||
| })); | ||
| } | ||
| } | ||
| const metrics = result.topogram_proof_metrics || {}; | ||
| smells.push(...implementerAttentionSmells(result)); | ||
| const actualScaffold = Number(metrics.actual_topogram_scaffold_calls || 0) + scaffoldRunsFromActions(result, eventLog); | ||
| if (Number(metrics.expected_topogram_scaffold_calls || 0) > actualScaffold) { | ||
| smells.push(smell("scaffold_expected_but_not_run", { | ||
| category: "generator", | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} expected scaffold work but did not run it.`, | ||
| evidence: { | ||
| arm: result.arm, | ||
| wave: result.wave, | ||
| expected: Number(metrics.expected_topogram_scaffold_calls || 0), | ||
| actual: actualScaffold | ||
| } | ||
| })); | ||
| } | ||
| } | ||
| return smells; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} report | ||
| * @param {AnyRecord|null} manifest | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function outcomes(report, manifest) { | ||
| return { | ||
| wave_gate_ready: Boolean(report?.wave_gate_ready ?? (report?.all_waves_completed && report?.all_waves_passed) ?? false), | ||
| all_waves_completed: Boolean(report?.all_waves_completed ?? false), | ||
| all_waves_passed: Boolean(report?.all_waves_passed ?? false), | ||
| failed_waves: Array.isArray(report?.failed_waves) ? report.failed_waves : [], | ||
| stabilization_ready: Boolean(report?.stabilization_ready ?? false), | ||
| final_hidden_pass_rate_scope: report?.summary?.final_hidden_pass_rate_scope || "final_state_only", | ||
| run_mode: report?.run_mode || manifest?.run_mode || null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} auditBundlePath | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function readAuditBundleManifest(auditBundlePath) { | ||
| if (!auditBundlePath) return null; | ||
| const root = path.resolve(auditBundlePath); | ||
| const direct = path.join(root, "audit-manifest.json"); | ||
| if (fs.existsSync(direct)) return readJsonIfExists(direct); | ||
| /** @type {string[]} */ | ||
| const allManifests = []; | ||
| if (fs.existsSync(root)) { | ||
| /** @param {string} dir */ | ||
| function walk(dir) { | ||
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | ||
| const absolute = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| walk(absolute); | ||
| } else if (entry.isFile() && entry.name === "audit-manifest.json") { | ||
| allManifests.push(absolute); | ||
| } | ||
| } | ||
| } | ||
| walk(root); | ||
| } | ||
| if (allManifests.length > 0) { | ||
| const bundles = allManifests.sort().map((file) => { | ||
| const manifest = readJsonIfExists(file) || {}; | ||
| return { | ||
| path: portablePath(root, file), | ||
| profile: manifest.profile || null, | ||
| focus: manifest.focus || null, | ||
| selector: manifest.selector || null, | ||
| artifacts_count: Array.isArray(manifest.artifacts) ? manifest.artifacts.length : 0 | ||
| }; | ||
| }); | ||
| return { | ||
| type: "audit_bundle_collection", | ||
| version: 1, | ||
| profile: bundles.find((bundle) => bundle.profile)?.profile || null, | ||
| bundle_count: bundles.length, | ||
| bundles, | ||
| artifacts_count: bundles.reduce((sum, bundle) => sum + Number(bundle.artifacts_count || 0), 0) | ||
| }; | ||
| } | ||
| const nestedRoot = path.join(root, "audit-bundle"); | ||
| if (!fs.existsSync(nestedRoot)) return null; | ||
| for (const entry of fs.readdirSync(nestedRoot).sort()) { | ||
| const manifest = path.join(nestedRoot, entry, "audit-manifest.json"); | ||
| if (fs.existsSync(manifest)) return readJsonIfExists(manifest); | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null} report | ||
| * @param {AnyRecord[]} usageLog | ||
| * @param {AnyRecord[]} results | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function tokenAccounting(report, usageLog, results) { | ||
| const byArm = report?.by_arm || {}; | ||
| /** @type {AnyRecord} */ | ||
| const actualByArm = {}; | ||
| const arms = [...new Set([ | ||
| ...Object.keys(byArm), | ||
| ...results.map((result) => result.arm).filter(Boolean) | ||
| ])]; | ||
| for (const arm of arms) { | ||
| actualByArm[arm] = byArm[arm] | ||
| ? { | ||
| total_tokens: byArm[arm].total_tokens || 0, | ||
| input_tokens: byArm[arm].input_tokens || 0, | ||
| output_tokens: byArm[arm].output_tokens || 0, | ||
| model_calls: byArm[arm].model_calls || 0 | ||
| } | ||
| : tokenTotals(usageLog.filter((entry) => entry.arm === arm)); | ||
| } | ||
| return { | ||
| actual_by_arm: actualByArm, | ||
| topogram_minus_vibe_tokens: report?.summary?.token_delta_topogram_minus_vibe ?? null, | ||
| agent_packet_estimated_tokens: results.reduce((sum, result) => sum + packetTokens(result), 0), | ||
| tool_payload_estimated_tokens: results.reduce((sum, result) => sum + Number(result.topogram_proof_metrics?.tool_payload_estimated_tokens || 0), 0), | ||
| caveat: "Actual API tokens are provider-reported when present; packet and payload tokens are deterministic estimates." | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} runDir | ||
| * @param {{ auditBundlePath?: string|null }} [options] | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function buildTraceAnalysis(runDir, options = {}) { | ||
| const runRoot = path.resolve(runDir); | ||
| const manifest = readJsonIfExists(path.join(runRoot, "run-manifest.json")); | ||
| const waveResults = readJsonIfExists(path.join(runRoot, "wave-results.json")); | ||
| if (!manifest || !Array.isArray(waveResults)) { | ||
| throw new Error("trace analyze requires a run directory with run-manifest.json and wave-results.json"); | ||
| } | ||
| const report = readJsonIfExists(path.join(runRoot, "report.json")); | ||
| const usageLog = readJsonlIfExists(path.join(runRoot, "usage-log.jsonl")); | ||
| const eventLog = readJsonlIfExists(path.join(runRoot, "event-log.jsonl")); | ||
| const auditBundle = readAuditBundleManifest(options.auditBundlePath || null); | ||
| const reportQualityScores = report?.quality_scores || null; | ||
| const smells = [ | ||
| ...attentionSmells(waveResults, usageLog, eventLog, runRoot, manifest), | ||
| ...qualityScoreSmells(reportQualityScores) | ||
| ]; | ||
| const context = evaluationContext(manifest); | ||
| const outcome = outcomes(report, manifest); | ||
| const gaps = evidenceGaps(context); | ||
| return { | ||
| type: "topogram_trace_analysis", | ||
| version: 1, | ||
| run_id: manifest.run_id || path.basename(runRoot), | ||
| run_dir: portablePath(process.cwd(), runRoot), | ||
| evaluation_context: context, | ||
| matrix_position: matrixPosition(context), | ||
| expected_workflow: expectedWorkflow(manifest, auditBundle), | ||
| observed_workflow: observedWorkflow(manifest, waveResults, usageLog, eventLog), | ||
| token_accounting: tokenAccounting(report, usageLog, waveResults), | ||
| quality_scores: reportQualityScores, | ||
| attention_smells: smells, | ||
| outcomes: outcome, | ||
| evidence_gaps: gaps, | ||
| publication_readiness: publicationReadiness(outcome, gaps), | ||
| recommendations: recommendations(smells, gaps), | ||
| caveats: [ | ||
| "Trace analysis is advisory evidence; token deltas and attention smells are not pass/fail gates.", | ||
| auditBundle ? "Expected workflow evidence came from the supplied audit bundle." : "No audit bundle was supplied, so expected workflow evidence is limited to the run manifest." | ||
| ] | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} runA | ||
| * @param {string} runB | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function compareTraceAnalyses(runA, runB) { | ||
| const left = buildTraceAnalysis(runA); | ||
| const right = buildTraceAnalysis(runB); | ||
| const leftTopogram = left.token_accounting?.actual_by_arm?.topogram?.total_tokens || 0; | ||
| const rightTopogram = right.token_accounting?.actual_by_arm?.topogram?.total_tokens || 0; | ||
| return { | ||
| type: "topogram_trace_compare", | ||
| version: 1, | ||
| left: { | ||
| run_id: left.run_id, | ||
| topogram_tokens: leftTopogram, | ||
| smells: left.attention_smells.length, | ||
| all_waves_passed: left.outcomes.all_waves_passed | ||
| }, | ||
| right: { | ||
| run_id: right.run_id, | ||
| topogram_tokens: rightTopogram, | ||
| smells: right.attention_smells.length, | ||
| all_waves_passed: right.outcomes.all_waves_passed | ||
| }, | ||
| delta: { | ||
| topogram_tokens_right_minus_left: rightTopogram - leftTopogram, | ||
| attention_smells_right_minus_left: right.attention_smells.length - left.attention_smells.length | ||
| } | ||
| }; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord[]} eventLog | ||
| * @param {AnyRecord} result | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function eventLogToolEventsForResult(eventLog, result) { | ||
| return eventLog | ||
| .filter((event) => | ||
| event.type === "tool_call" | ||
| && event.trial === result.trial | ||
| && event.arm === result.arm | ||
| && event.wave === result.wave | ||
| ) | ||
| .map((event) => ({ | ||
| tool: event.name || null, | ||
| args: event.args || {}, | ||
| result: event.summary || {}, | ||
| input_ref: event.input_ref || null, | ||
| output_ref: event.output_ref || null | ||
| })); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} result | ||
| * @param {AnyRecord[]} [eventLog] | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function observedToolEvents(result, eventLog = []) { | ||
| const fromEventLog = eventLogToolEventsForResult(eventLog, result); | ||
| return fromEventLog.length > 0 | ||
| ? fromEventLog | ||
| : (Array.isArray(result.tool_usage) ? result.tool_usage : []); | ||
| } | ||
| /** | ||
| * @param {AnyRecord[]} eventLog | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function eventLogSummary(eventLog) { | ||
| /** @type {Record<string, number>} */ | ||
| const counts = {}; | ||
| for (const event of eventLog) { | ||
| const type = String(event.type || "unknown"); | ||
| counts[type] = (counts[type] || 0) + 1; | ||
| } | ||
| return { | ||
| path: "event-log.jsonl", | ||
| primary: eventLog.length > 0, | ||
| events: eventLog.length, | ||
| event_counts: counts | ||
| }; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord} analysis | ||
| * @returns {string} | ||
| */ | ||
| export function formatTraceMarkdown(analysis) { | ||
| const lines = []; | ||
| lines.push("# Topogram Trace Report"); | ||
| lines.push(""); | ||
| lines.push(`Run: ${analysis.run_id}`); | ||
| lines.push(`Mode: ${analysis.outcomes?.run_mode || "unknown"}`); | ||
| lines.push(`Scenario: ${analysis.matrix_position?.scenario_id || "unknown"}`); | ||
| lines.push(`Evaluator profile: ${analysis.matrix_position?.evaluator_profile || analysis.evaluation_context?.evaluator_profile || "unknown"}`); | ||
| if (Array.isArray(analysis.evaluation_context?.evaluators_run) && analysis.evaluation_context.evaluators_run.length > 0) { | ||
| lines.push(`Evaluators run: ${analysis.evaluation_context.evaluators_run.join(", ")}.`); | ||
| } | ||
| lines.push(`Claim: ${analysis.evaluation_context?.claim || "not specified"}`); | ||
| lines.push(`Publication readiness: ${analysis.publication_readiness?.status || "unknown"}`); | ||
| lines.push(`Outcome: ${analysis.outcomes?.all_waves_passed ? "all waves passed" : "one or more waves failed"}`); | ||
| if ((analysis.evidence_gaps || []).length > 0) { | ||
| lines.push(""); | ||
| lines.push("## Evidence Gaps"); | ||
| for (const gap of analysis.evidence_gaps || []) { | ||
| lines.push(`- **${gap.dimension}**: ${gap.reason}`); | ||
| } | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Token Accounting"); | ||
| lines.push(""); | ||
| lines.push("| Arm | Tokens | Model calls |"); | ||
| lines.push("| --- | ---: | ---: |"); | ||
| for (const [arm, totals] of Object.entries(analysis.token_accounting?.actual_by_arm || {})) { | ||
| lines.push(`| ${arm} | ${totals.total_tokens || 0} | ${totals.model_calls || 0} |`); | ||
| } | ||
| if (analysis.token_accounting?.topogram_minus_vibe_tokens !== null) { | ||
| lines.push(""); | ||
| lines.push(`Topogram minus vibe tokens: ${analysis.token_accounting.topogram_minus_vibe_tokens}.`); | ||
| } | ||
| if (analysis.quality_scores?.by_arm) { | ||
| lines.push(""); | ||
| lines.push("## Product And Maintainability Scores"); | ||
| lines.push(""); | ||
| /** @type {AnyRecord[]} */ | ||
| const dimensions = Array.isArray(analysis.quality_scores.dimensions) ? analysis.quality_scores.dimensions : []; | ||
| /** @type {AnyRecord[]} */ | ||
| const columns = dimensions.length > 0 ? dimensions : [{ id: "product_api_quality", label: "Product API Quality" }]; | ||
| lines.push(`| Arm | ${columns.map((column) => column.label || column.id).join(" | ")} |`); | ||
| lines.push(`| --- | ${columns.map(() => "---:").join(" | ")} |`); | ||
| for (const [arm, scores] of Object.entries(analysis.quality_scores.by_arm || {})) { | ||
| lines.push(`| ${arm} | ${columns.map((column) => scores[column.id]?.score_mean ?? "not scored").join(" | ")} |`); | ||
| } | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Attention Smells"); | ||
| if ((analysis.attention_smells || []).length === 0) { | ||
| lines.push(""); | ||
| lines.push("No major attention smells detected."); | ||
| } else { | ||
| for (const entry of analysis.attention_smells || []) { | ||
| lines.push(`- **${entry.type}** (${entry.severity}): ${entry.message}`); | ||
| } | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Recommendations"); | ||
| for (const entry of analysis.recommendations || []) { | ||
| lines.push(`- **${entry.category}**: ${entry.recommendation}`); | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Caveats"); | ||
| for (const caveat of analysis.caveats || []) lines.push(`- ${caveat}`); | ||
| lines.push(""); | ||
| return lines.join("\n"); | ||
| } | ||
| /** | ||
| * @param {AnyRecord} analysis | ||
| * @returns {string} | ||
| */ | ||
| export function formatExperimentLessonDraft(analysis) { | ||
| const topogram = analysis.token_accounting?.actual_by_arm?.topogram; | ||
| const vibe = analysis.token_accounting?.actual_by_arm?.vibe; | ||
| const lines = []; | ||
| lines.push(`# Experiment Lesson: ${analysis.run_id}`); | ||
| lines.push(""); | ||
| lines.push("## What We Ran"); | ||
| lines.push(""); | ||
| lines.push(`Run mode: \`${analysis.outcomes?.run_mode || "unknown"}\`.`); | ||
| lines.push(`Scenario: \`${analysis.matrix_position?.scenario_id || "unknown"}\`.`); | ||
| lines.push(`Claim: ${analysis.evaluation_context?.claim || "not specified"}.`); | ||
| lines.push(`Outcome: ${analysis.outcomes?.all_waves_passed ? "all waves passed" : "one or more waves failed"}.`); | ||
| lines.push(""); | ||
| lines.push("## What We Learned"); | ||
| lines.push(""); | ||
| if (topogram && vibe) { | ||
| lines.push(`Topogram used ${topogram.total_tokens || 0} tokens; vibe used ${vibe.total_tokens || 0} tokens.`); | ||
| } else if (topogram) { | ||
| lines.push(`Topogram used ${topogram.total_tokens || 0} tokens.`); | ||
| } | ||
| lines.push("Token and tool deltas are attention signals, not standalone proof of product value."); | ||
| lines.push(""); | ||
| lines.push("## Trace Findings"); | ||
| for (const entry of analysis.attention_smells || []) { | ||
| lines.push(`- ${entry.type}: ${entry.message}`); | ||
| } | ||
| if ((analysis.attention_smells || []).length === 0) { | ||
| lines.push("- No major attention smells were detected."); | ||
| } | ||
| if ((analysis.evidence_gaps || []).length > 0) { | ||
| lines.push(""); | ||
| lines.push("## Evidence Gaps"); | ||
| for (const gap of analysis.evidence_gaps || []) { | ||
| lines.push(`- ${gap.dimension}: ${gap.reason}`); | ||
| } | ||
| } | ||
| lines.push(""); | ||
| lines.push("## Next Improvements"); | ||
| for (const entry of analysis.recommendations || []) { | ||
| lines.push(`- ${entry.recommendation}`); | ||
| } | ||
| lines.push(""); | ||
| lines.push("> Draft generated by Topogram trace. Review before publishing."); | ||
| lines.push(""); | ||
| return lines.join("\n"); | ||
| } |
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {string} filePath | ||
| * @returns {AnyRecord|null} | ||
| */ | ||
| function readJsonIfExists(filePath) { | ||
| if (!fs.existsSync(filePath)) return null; | ||
| return JSON.parse(fs.readFileSync(filePath, "utf8")); | ||
| } | ||
| /** | ||
| * @param {string} base | ||
| * @param {string} target | ||
| * @returns {string} | ||
| */ | ||
| function portablePath(base, target) { | ||
| return path.relative(base, target).split(path.sep).join("/") || "."; | ||
| } | ||
| /** | ||
| * @param {string} runRoot | ||
| * @param {AnyRecord|null} manifest | ||
| * @param {AnyRecord} result | ||
| * @returns {string[]} | ||
| */ | ||
| function futureEndpointPaths(runRoot, manifest, result) { | ||
| if (!manifest) return []; | ||
| const waves = Array.isArray(manifest.waves) ? manifest.waves : []; | ||
| const currentIndex = waves.findIndex((wave) => wave.id === result.wave); | ||
| if (currentIndex === -1) return []; | ||
| const contract = readJsonIfExists(path.join(runRoot, "inputs", "evaluator", "public-api-contract.json")); | ||
| const futureWaves = new Set(waves.slice(currentIndex + 1).map((wave) => wave.id)); | ||
| return Array.isArray(contract?.endpoints) | ||
| ? contract.endpoints | ||
| .filter((endpoint) => futureWaves.has(String(endpoint.wave || ""))) | ||
| .map((endpoint) => String(endpoint.path || "")) | ||
| .filter(Boolean) | ||
| : []; | ||
| } | ||
| /** | ||
| * @param {string} runRoot | ||
| * @param {AnyRecord|null} manifest | ||
| * @param {AnyRecord} result | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function futureEndpointWriteEvidence(runRoot, manifest, result) { | ||
| const paths = futureEndpointPaths(runRoot, manifest, result); | ||
| if (paths.length === 0) return []; | ||
| const dir = path.join(runRoot, "trials", `trial-${result.trial}`, String(result.arm || ""), String(result.wave || ""), "model-calls"); | ||
| if (!fs.existsSync(dir)) return []; | ||
| const evidence = []; | ||
| for (const entry of fs.readdirSync(dir).filter(/** @param {string} name */ (name) => /-response\.json$/.test(name)).sort()) { | ||
| const payload = readJsonIfExists(path.join(dir, entry)); | ||
| const outputs = Array.isArray(payload?.output) ? /** @type {AnyRecord[]} */ (payload.output) : []; | ||
| for (const output of outputs) { | ||
| if (output?.type !== "function_call" || !["write_file", "replace_file_text"].includes(String(output.name || ""))) continue; | ||
| /** @type {AnyRecord} */ | ||
| let args = {}; | ||
| try { | ||
| args = JSON.parse(String(output.arguments || "{}")); | ||
| } catch { | ||
| args = {}; | ||
| } | ||
| const text = [args.content, args.replacement].filter(Boolean).join("\n"); | ||
| const matched = paths.filter((apiPath) => text.includes(apiPath)); | ||
| if (matched.length > 0) { | ||
| evidence.push({ | ||
| response_ref: portablePath(runRoot, path.join(dir, entry)), | ||
| tool: output.name, | ||
| path: args.path || null, | ||
| future_endpoint_paths: [...new Set(matched)].slice(0, 12) | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return evidence; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {string} type | ||
| * @param {AnyRecord} details | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function smell(type, details) { | ||
| return { | ||
| type, | ||
| severity: details.severity || "medium", | ||
| category: details.category || "implementer", | ||
| message: details.message, | ||
| evidence: details.evidence || {} | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} result | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function implementerAttentionSmells(result) { | ||
| const metrics = result.topogram_proof_metrics || {}; | ||
| const evidence = { arm: result.arm, wave: result.wave }; | ||
| const rows = []; | ||
| if (Number(metrics.implementer_action_public_check_failures || 0) > 0) { | ||
| rows.push(smell("implementer_action_public_check_failed", { | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} applied an implementer action that failed the public check.`, | ||
| evidence: { ...evidence, failures: Number(metrics.implementer_action_public_check_failures || 0) } | ||
| })); | ||
| } | ||
| if (Number(metrics.manual_rewrite_after_action_failure || 0) > 0) { | ||
| rows.push(smell("manual_rewrite_after_implementer_failure", { | ||
| severity: "high", | ||
| message: `${result.arm}/${result.wave} needed manual app edits after an implementer action failure.`, | ||
| evidence: { ...evidence, rewrites: Number(metrics.manual_rewrite_after_action_failure || 0) } | ||
| })); | ||
| } | ||
| if (Number(metrics.code_edit_ready_without_action || 0) > 0) { | ||
| rows.push(smell("code_edit_ready_without_action", { | ||
| message: `${result.arm}/${result.wave} reached code_edit_ready without an executable implementer action.`, | ||
| evidence: { ...evidence, count: Number(metrics.code_edit_ready_without_action || 0) } | ||
| })); | ||
| } | ||
| return rows; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {string} type | ||
| * @param {AnyRecord} details | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function smell(type, details) { | ||
| return { | ||
| type, | ||
| severity: details.severity || "medium", | ||
| category: details.category || "product_quality", | ||
| message: details.message, | ||
| evidence: details.evidence || {} | ||
| }; | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} qualityScores | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function qualityScoreSmells(qualityScores) { | ||
| const deltas = qualityScores?.deltas || {}; | ||
| const topogramEntries = Array.isArray(qualityScores?.by_arm?.topogram?.per_trial) | ||
| ? /** @type {AnyRecord[]} */ (qualityScores.by_arm.topogram.per_trial) | ||
| : []; | ||
| const topogramUxEntries = Array.isArray(qualityScores?.by_arm?.topogram?.per_trial) | ||
| ? /** @type {AnyRecord[]} */ (qualityScores.by_arm.topogram.per_trial).map((entry) => entry.ux_completeness_static).filter(Boolean) | ||
| : []; | ||
| const latestTopogramUx = topogramUxEntries.at(-1) || null; | ||
| /** @type {AnyRecord[]} */ | ||
| const smells = []; | ||
| const productUiDelta = Number(deltas.product_ui_quality_topogram_minus_vibe ?? 0); | ||
| if (productUiDelta < -10) { | ||
| smells.push(smell("product_ui_quality_gap", { | ||
| severity: "high", | ||
| message: `Topogram product UI quality scored ${Math.abs(productUiDelta)} points lower than vibe.`, | ||
| evidence: { topogram_minus_vibe: productUiDelta } | ||
| })); | ||
| } | ||
| const uxDelta = Number(deltas.ux_completeness_static_topogram_minus_vibe ?? 0); | ||
| if (uxDelta < -10) { | ||
| smells.push(smell("ux_completeness_gap", { | ||
| severity: "medium", | ||
| message: `Topogram UX completeness scored ${Math.abs(uxDelta)} points lower than vibe.`, | ||
| evidence: { | ||
| topogram_minus_vibe: uxDelta, | ||
| missing_terms: latestTopogramUx?.missing_terms || {} | ||
| } | ||
| })); | ||
| } | ||
| const structureDelta = Number(deltas.semantic_ui_structure_static_topogram_minus_vibe ?? deltas.visual_design_quality_topogram_minus_vibe ?? 0); | ||
| if (structureDelta < -10) { | ||
| smells.push(smell("semantic_ui_structure_static_gap", { | ||
| severity: "medium", | ||
| message: `Topogram semantic UI structure scored ${Math.abs(structureDelta)} points lower than vibe.`, | ||
| evidence: { topogram_minus_vibe: structureDelta } | ||
| })); | ||
| } | ||
| const accessibilityDelta = Number(deltas.accessibility_usability_topogram_minus_vibe ?? 0); | ||
| if (accessibilityDelta < -10) { | ||
| smells.push(smell("accessibility_usability_gap", { | ||
| severity: "medium", | ||
| message: `Topogram accessibility usability scored ${Math.abs(accessibilityDelta)} points lower than vibe.`, | ||
| evidence: { topogram_minus_vibe: accessibilityDelta } | ||
| })); | ||
| } | ||
| const browserRenderDelta = Number(deltas.browser_render_quality_topogram_minus_vibe ?? 0); | ||
| if (browserRenderDelta < -10) { | ||
| smells.push(smell("browser_render_quality_gap", { | ||
| severity: "high", | ||
| message: `Topogram browser render quality scored ${Math.abs(browserRenderDelta)} points lower than vibe.`, | ||
| evidence: { topogram_minus_vibe: browserRenderDelta } | ||
| })); | ||
| } | ||
| const screenshotDelta = Number(deltas.screenshot_visual_evidence_topogram_minus_vibe ?? 0); | ||
| if (screenshotDelta < -10) { | ||
| smells.push(smell("screenshot_visual_evidence_gap", { | ||
| severity: "medium", | ||
| message: `Topogram screenshot evidence scored ${Math.abs(screenshotDelta)} points lower than vibe.`, | ||
| evidence: { topogram_minus_vibe: screenshotDelta } | ||
| })); | ||
| } | ||
| const browserAccessibilityDelta = Number(deltas.browser_accessibility_static_topogram_minus_vibe ?? 0); | ||
| if (browserAccessibilityDelta < -10) { | ||
| smells.push(smell("browser_accessibility_static_gap", { | ||
| severity: "medium", | ||
| message: `Topogram browser accessibility scored ${Math.abs(browserAccessibilityDelta)} points lower than vibe.`, | ||
| evidence: { topogram_minus_vibe: browserAccessibilityDelta } | ||
| })); | ||
| } | ||
| const latestBrowser = topogramEntries.map((entry) => entry.browser_render_quality).filter(Boolean).at(-1) || null; | ||
| const style = latestBrowser?.evidence?.style || null; | ||
| const styleChecks = Array.isArray(style?.checks) ? /** @type {AnyRecord[]} */ (style.checks) : []; | ||
| if (style?.default_browser_style_detected || styleChecks.some((entry) => entry.default_browser_style_detected)) { | ||
| smells.push(smell("default_browser_style_detected", { | ||
| severity: "medium", | ||
| category: "product_quality", | ||
| message: "Topogram browser evidence indicates the rendered UI is using mostly default browser styling.", | ||
| evidence: { | ||
| browser_summary: latestBrowser?.evidence?.browser_summary || null, | ||
| style | ||
| } | ||
| })); | ||
| } | ||
| const absoluteThresholds = { | ||
| product_ui_quality: 90, | ||
| ux_completeness_static: 90, | ||
| semantic_ui_structure_static: 90, | ||
| accessibility_usability: 90, | ||
| browser_render_quality: 95, | ||
| browser_accessibility_static: 95, | ||
| maintainability_static: 80 | ||
| }; | ||
| for (const [dimension, threshold] of Object.entries(absoluteThresholds)) { | ||
| const score = Number(qualityScores?.by_arm?.topogram?.[dimension]?.score_mean ?? NaN); | ||
| if (Number.isFinite(score) && score < threshold) { | ||
| smells.push(smell("absolute_quality_gap", { | ||
| severity: score < 70 ? "high" : "medium", | ||
| category: "product_quality", | ||
| message: `Topogram ${dimension} scored ${score}, below the ${threshold} threshold.`, | ||
| evidence: { dimension, score, threshold } | ||
| })); | ||
| } | ||
| } | ||
| return smells; | ||
| } |
| // @ts-check | ||
| /** | ||
| * @typedef {Record<string, any>} AnyRecord | ||
| */ | ||
| /** | ||
| * @param {AnyRecord[]} smells | ||
| * @param {AnyRecord[]} gaps | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| export function recommendations(smells, gaps = []) { | ||
| const byType = new Set(smells.map((entry) => entry.type)); | ||
| /** @type {AnyRecord[]} */ | ||
| const rows = []; | ||
| if (byType.has("large_actual_vs_packet_token_delta") || byType.has("excessive_file_reads")) { | ||
| rows.push({ | ||
| category: "packet", | ||
| recommendation: "Tighten work packets with smaller edit anchors, endpoint-specific snippets, and fewer full-file reads." | ||
| }); | ||
| } | ||
| if (byType.has("repeated_work_state") || byType.has("app_edit_before_code_ready") || byType.has("app_edit_after_proof_complete")) { | ||
| rows.push({ | ||
| category: "workflow", | ||
| recommendation: "Make work next states more decisive, stop waves immediately after proof passes, and keep harness/tool gating aligned with the packet state." | ||
| }); | ||
| } | ||
| if (byType.has("scaffold_expected_but_not_run")) { | ||
| rows.push({ | ||
| category: "generator", | ||
| recommendation: "Improve scaffold status and generated patch plans so required scaffold work is harder to skip." | ||
| }); | ||
| } | ||
| if (byType.has("implementer_action_public_check_failed") || byType.has("manual_rewrite_after_implementer_failure") || byType.has("code_edit_ready_without_action")) { | ||
| rows.push({ | ||
| category: "implementer", | ||
| recommendation: "Improve implementer anchors, generated handler bodies, and failed-action recovery before treating packet actions as efficient maintained-code leverage." | ||
| }); | ||
| } | ||
| if (byType.has("proof_skipped")) { | ||
| rows.push({ | ||
| category: "proof", | ||
| recommendation: "Make proof commands explicit in the active packet and treat skipped checks as a run-quality warning." | ||
| }); | ||
| } | ||
| if (byType.has("future_wave_endpoint_implemented_early")) { | ||
| rows.push({ | ||
| category: "evaluation_design", | ||
| recommendation: "Expose only current-and-prior-wave acceptance contracts during each wave so early implementation cannot contaminate later wave evidence." | ||
| }); | ||
| } | ||
| if (byType.has("product_ui_quality_gap")) { | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: "Improve visible dashboard contracts, UI implementation targets, or product-scoring evidence before treating API efficiency as product parity." | ||
| }); | ||
| } | ||
| if (byType.has("ux_completeness_gap")) { | ||
| const uxSmell = smells.find((entry) => entry.type === "ux_completeness_gap"); | ||
| const missingTerms = Object.values(uxSmell?.evidence?.missing_terms || {}) | ||
| .flat() | ||
| .map(String) | ||
| .filter(Boolean); | ||
| const suffix = missingTerms.length > 0 | ||
| ? ` Missing terms: ${[...new Set(missingTerms)].slice(0, 12).join(", ")}.` | ||
| : ""; | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: `Improve domain copy, role-aware affordances, empty/active states, and visible actions in the active work packet or implementation path.${suffix}` | ||
| }); | ||
| } | ||
| if (byType.has("semantic_ui_structure_static_gap")) { | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: "Improve semantic dashboard structure, hierarchy, and visible action affordances before treating deterministic UI text coverage as product-structure parity." | ||
| }); | ||
| } | ||
| if (byType.has("default_browser_style_detected")) { | ||
| rows.push({ | ||
| category: "implementer", | ||
| recommendation: "Improve the active implementer rendered shell so browser evidence shows author styling, touch-safe controls, grouped sections, and responsive spacing instead of default browser chrome." | ||
| }); | ||
| } | ||
| if (byType.has("absolute_quality_gap")) { | ||
| const dimensions = smells | ||
| .filter((entry) => entry.type === "absolute_quality_gap") | ||
| .map((entry) => entry.evidence?.dimension) | ||
| .filter(Boolean); | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: `Fix absolute product-quality gaps before making favorable claims: ${[...new Set(dimensions)].join(", ")}.` | ||
| }); | ||
| } | ||
| if (byType.has("accessibility_usability_gap")) { | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: "Improve landmarks, headings, labeled controls, page metadata, and role-visibility evidence before making accessibility or usability claims." | ||
| }); | ||
| } | ||
| if (byType.has("browser_render_quality_gap")) { | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: "Inspect browser evidence artifacts and fix routes that fail to render, paint visible content, or satisfy required dashboard copy in a real browser." | ||
| }); | ||
| } | ||
| if (byType.has("screenshot_visual_evidence_gap")) { | ||
| rows.push({ | ||
| category: "evaluation_design", | ||
| recommendation: "Capture complete desktop and mobile screenshot evidence for each required dashboard and role-variant route before claiming visual parity." | ||
| }); | ||
| } | ||
| if (byType.has("browser_accessibility_static_gap")) { | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: "Use browser DOM evidence to fix rendered landmarks, headings, named controls, overflow, tap targets, and clipping issues." | ||
| }); | ||
| } | ||
| const nonVisualReviewGaps = gaps.filter((gap) => String(gap.dimension || "") !== "visual_review_human"); | ||
| if (nonVisualReviewGaps.length > 0) { | ||
| rows.push({ | ||
| category: "evaluation_design", | ||
| recommendation: "Add scorers or proof targets for declared evidence gaps before using this run as a broad product-quality claim." | ||
| }); | ||
| } | ||
| if (gaps.some((gap) => String(gap.dimension || "") === "visual_review_human")) { | ||
| rows.push({ | ||
| category: "evaluation_design", | ||
| recommendation: "Treat screenshots as evidence for human or vision review; visual design quality remains unscored until that review receipt exists." | ||
| }); | ||
| } | ||
| const productQualityGaps = gaps | ||
| .map((gap) => String(gap.dimension || "")) | ||
| .filter((dimension) => /product|ui|ux|visual|accessibility|assistive|screenshot|maintainability/.test(dimension)); | ||
| if (productQualityGaps.length > 0) { | ||
| rows.push({ | ||
| category: "product_quality", | ||
| recommendation: `Add the remaining product-quality evidence before publishing broad claims: ${[...new Set(productQualityGaps)].join(", ")}.` | ||
| }); | ||
| } | ||
| if (rows.length === 0) { | ||
| rows.push({ | ||
| category: "experiment", | ||
| recommendation: "No major trace smells were detected; compare pass rate, token cost, and human review before changing workflow." | ||
| }); | ||
| } | ||
| return rows; | ||
| } |
| // @ts-check | ||
| import { | ||
| FEATURE_IDENTIFIER_PATTERN | ||
| } from "../kinds.js"; | ||
| import { | ||
| pushError, | ||
| valueAsArray | ||
| } from "../utils.js"; | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement */ | ||
| function validateFeatureIdentifier(errors, statement) { | ||
| if (!FEATURE_IDENTIFIER_PATTERN.test(statement.id)) { | ||
| pushError( | ||
| errors, | ||
| `Feature identifier '${statement.id}' must match ${FEATURE_IDENTIFIER_PATTERN.source}`, | ||
| statement.loc | ||
| ); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @param {TopogramRegistry} registry | ||
| * @param {string} key | ||
| * @param {string} expectedKind | ||
| */ | ||
| function validateFeatureRefList(errors, statement, fieldMap, registry, key, expectedKind) { | ||
| const field = fieldMap.get(key)?.[0]; | ||
| if (!field) return; | ||
| if (field.value.type !== "list") { | ||
| pushError(errors, `Field '${key}' on feature ${statement.id} must be list, found ${field.value.type}`, field.loc); | ||
| return; | ||
| } | ||
| for (const item of valueAsArray(field.value)) { | ||
| if (item.type !== "symbol") { | ||
| pushError(errors, `Field '${key}' on feature ${statement.id} must only contain symbols`, item.loc); | ||
| continue; | ||
| } | ||
| const target = registry.get(item.value); | ||
| if (!target) { | ||
| pushError(errors, `Missing reference '${item.value}' in field '${key}' on feature ${statement.id}`, item.loc); | ||
| continue; | ||
| } | ||
| if (target.kind !== expectedKind) { | ||
| pushError(errors, `Field '${key}' on feature ${statement.id} must reference ${expectedKind}, found ${target.kind} '${target.id}'`, item.loc); | ||
| } | ||
| } | ||
| } | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap @param {TopogramRegistry} registry */ | ||
| export function validateFeature(errors, statement, fieldMap, registry) { | ||
| if (statement.kind !== "feature") { | ||
| return; | ||
| } | ||
| validateFeatureIdentifier(errors, statement); | ||
| validateFeatureRefList(errors, statement, fieldMap, registry, "entities", "entity"); | ||
| validateFeatureRefList(errors, statement, fieldMap, registry, "capabilities", "capability"); | ||
| validateFeatureRefList(errors, statement, fieldMap, registry, "endpoints", "endpoint"); | ||
| validateFeatureRefList(errors, statement, fieldMap, registry, "seed_data", "seed_data"); | ||
| } |
| // @ts-check | ||
| import { | ||
| UI_SECTION_KINDS | ||
| } from "../kinds.js"; | ||
| import { | ||
| getFieldValue, | ||
| pushError, | ||
| symbolValue, | ||
| symbolValues | ||
| } from "../utils.js"; | ||
| import { validateGeneratedHttpPath } from "../safe-values.js"; | ||
| export const NAVPOINT_AUTH_MODES = new Set(["public", "user", "authenticated", "role_based", "manager", "admin"]); | ||
| export const ENDPOINT_AUTH_MODES = new Set(["none", "user", "manager", "admin"]); | ||
| export const ENDPOINT_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); | ||
| export const ENDPOINT_REQUEST_PLACEMENTS = new Set(["body", "query", "path", "none"]); | ||
| export const ENDPOINT_RESPONSE_RESULTS = new Set(["item", "collection", "none"]); | ||
| export const ENDPOINT_RESPONSE_CONTAINERS = new Set(["json_object", "json_array", "none"]); | ||
| export const ENDPOINT_ID_PATTERN = /^endpoint_[a-z]+_[a-z0-9_]+$/; | ||
| /** | ||
| * @param {string | null | undefined} path | ||
| * @returns {string[]} | ||
| */ | ||
| export function pathParams(path) { | ||
| if (!path) return []; | ||
| return [...path.matchAll(/(^|\/):([A-Za-z][A-Za-z0-9_]*)/g)].map((match) => match[2]); | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @param {TopogramRegistry} registry | ||
| * @returns {void} | ||
| */ | ||
| export function validateNavpoint(errors, statement, fieldMap, registry) { | ||
| if (statement.kind !== "navpoint") { | ||
| return; | ||
| } | ||
| const path = pathValue(getFieldValue(statement, "path")); | ||
| if (path && !path.startsWith("/")) { | ||
| pushError(errors, `Navpoint ${statement.id} path must be absolute`, fieldMap.get("path")?.[0]?.loc || statement.loc); | ||
| } | ||
| if (path && /^\/api(\/|$)/.test(path)) { | ||
| pushError(errors, `Navpoint ${statement.id} uses API-looking path '${path}'. Use navpoint for UI navigation and endpoint for HTTP/API behavior.`, fieldMap.get("path")?.[0]?.loc || statement.loc); | ||
| } | ||
| const declaredParams = symbolValues(getFieldValue(statement, "params")); | ||
| const foundParams = pathParams(path); | ||
| if (declaredParams.join("|") !== foundParams.join("|")) { | ||
| pushError( | ||
| errors, | ||
| `Navpoint ${statement.id} params must match path params [${foundParams.join(", ")}]`, | ||
| fieldMap.get("params")?.[0]?.loc || fieldMap.get("path")?.[0]?.loc || statement.loc | ||
| ); | ||
| } | ||
| validateRef(errors, statement, fieldMap, registry, "screen", "screen"); | ||
| const screenId = symbolValue(getFieldValue(statement, "screen")); | ||
| const screen = screenId ? registry.get(screenId) : null; | ||
| if (screen?.kind === "screen") { | ||
| if (!getFieldValue(screen, "layout")) { | ||
| pushError(errors, `Navpoint ${statement.id} screen '${screenId}' must declare a layout`, fieldMap.get("screen")?.[0]?.loc || statement.loc); | ||
| } | ||
| const renders = getFieldValue(screen, "renders"); | ||
| if (!renders || renders.type !== "block" || renders.entries.length === 0) { | ||
| pushError(errors, `Navpoint ${statement.id} screen '${screenId}' must declare at least one renders entry`, fieldMap.get("screen")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| validateRef(errors, statement, fieldMap, registry, "loader", "capability"); | ||
| validateRef(errors, statement, fieldMap, registry, "action", "capability"); | ||
| const auth = symbolValue(getFieldValue(statement, "auth")); | ||
| if (auth && !NAVPOINT_AUTH_MODES.has(auth)) { | ||
| pushError(errors, `Navpoint ${statement.id} auth must be one of ${[...NAVPOINT_AUTH_MODES].join(", ")}`, fieldMap.get("auth")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @param {TopogramRegistry} registry | ||
| * @returns {void} | ||
| */ | ||
| export function validateEndpoint(errors, statement, fieldMap, registry) { | ||
| if (statement.kind !== "endpoint") { | ||
| return; | ||
| } | ||
| if (!ENDPOINT_ID_PATTERN.test(statement.id)) { | ||
| pushError(errors, `Endpoint ${statement.id} id must follow endpoint_<verb>_<resource_or_action>`, statement.loc); | ||
| } | ||
| const method = symbolValue(getFieldValue(statement, "method")); | ||
| if (method && !ENDPOINT_METHODS.has(method)) { | ||
| pushError(errors, `Endpoint ${statement.id} method must be one of ${[...ENDPOINT_METHODS].join(", ")}`, fieldMap.get("method")?.[0]?.loc || statement.loc); | ||
| } | ||
| const path = pathValue(getFieldValue(statement, "path")); | ||
| if (path && !path.startsWith("/")) { | ||
| pushError(errors, `Endpoint ${statement.id} path must be absolute`, fieldMap.get("path")?.[0]?.loc || statement.loc); | ||
| } | ||
| validateGeneratedHttpPath(errors, `Endpoint ${statement.id} path`, path, fieldMap.get("path")?.[0]?.loc || statement.loc); | ||
| const declaredParams = symbolValues(getFieldValue(statement, "params")); | ||
| const foundParams = pathParams(path); | ||
| if (declaredParams.join("|") !== foundParams.join("|")) { | ||
| pushError( | ||
| errors, | ||
| `Endpoint ${statement.id} params must match path params [${foundParams.join(", ")}]`, | ||
| fieldMap.get("params")?.[0]?.loc || fieldMap.get("path")?.[0]?.loc || statement.loc | ||
| ); | ||
| } | ||
| validateRef(errors, statement, fieldMap, registry, "capability", "capability"); | ||
| const successField = fieldMap.get("success")?.[0] || null; | ||
| if (successField && successField.value.type !== "symbol") { | ||
| pushError(errors, `Endpoint ${statement.id} success must be a symbol`, successField.loc); | ||
| } | ||
| const success = symbolValue(getFieldValue(statement, "success")); | ||
| if (success && !/^\d{3}$/.test(success)) { | ||
| pushError(errors, `Endpoint ${statement.id} success must be a 3-digit HTTP status`, fieldMap.get("success")?.[0]?.loc || statement.loc); | ||
| } | ||
| const auth = symbolValue(getFieldValue(statement, "auth")); | ||
| if (auth && !ENDPOINT_AUTH_MODES.has(auth)) { | ||
| pushError(errors, `Endpoint ${statement.id} auth must be one of ${[...ENDPOINT_AUTH_MODES].join(", ")}`, fieldMap.get("auth")?.[0]?.loc || statement.loc); | ||
| } | ||
| const request = symbolValue(getFieldValue(statement, "request")); | ||
| if (request && !ENDPOINT_REQUEST_PLACEMENTS.has(request)) { | ||
| pushError(errors, `Endpoint ${statement.id} request must be one of ${[...ENDPOINT_REQUEST_PLACEMENTS].join(", ")}`, fieldMap.get("request")?.[0]?.loc || statement.loc); | ||
| } | ||
| const responseResult = symbolValue(getFieldValue(statement, "response_result")); | ||
| if (responseResult && !ENDPOINT_RESPONSE_RESULTS.has(responseResult)) { | ||
| pushError(errors, `Endpoint ${statement.id} response_result must be one of ${[...ENDPOINT_RESPONSE_RESULTS].join(", ")}`, fieldMap.get("response_result")?.[0]?.loc || statement.loc); | ||
| } | ||
| const responseContainer = symbolValue(getFieldValue(statement, "response_container")); | ||
| if (responseContainer && !ENDPOINT_RESPONSE_CONTAINERS.has(responseContainer)) { | ||
| pushError(errors, `Endpoint ${statement.id} response_container must be one of ${[...ENDPOINT_RESPONSE_CONTAINERS].join(", ")}`, fieldMap.get("response_container")?.[0]?.loc || statement.loc); | ||
| } | ||
| validateRef(errors, statement, fieldMap, registry, "response_entity", "entity"); | ||
| if (responseResult || responseContainer || getFieldValue(statement, "response_entity")) { | ||
| const result = responseResult || null; | ||
| const container = responseContainer || null; | ||
| if (!result) { | ||
| pushError(errors, `Endpoint ${statement.id} response_result is required when response intent fields are present`, fieldMap.get("response_result")?.[0]?.loc || statement.loc); | ||
| } | ||
| if (!container) { | ||
| pushError(errors, `Endpoint ${statement.id} response_container is required when response intent fields are present`, fieldMap.get("response_container")?.[0]?.loc || statement.loc); | ||
| } | ||
| if ((result === "item" || result === "collection") && !symbolValue(getFieldValue(statement, "response_entity"))) { | ||
| pushError(errors, `Endpoint ${statement.id} response_entity is required for '${result}' responses`, fieldMap.get("response_entity")?.[0]?.loc || statement.loc); | ||
| } | ||
| if (result === "collection" && container && container !== "json_array") { | ||
| pushError(errors, `Endpoint ${statement.id} response_container must be json_array for collection responses`, fieldMap.get("response_container")?.[0]?.loc || statement.loc); | ||
| } | ||
| if (result === "item" && container && container !== "json_object") { | ||
| pushError(errors, `Endpoint ${statement.id} response_container must be json_object for item responses`, fieldMap.get("response_container")?.[0]?.loc || statement.loc); | ||
| } | ||
| if (result === "none" && container && container !== "none") { | ||
| pushError(errors, `Endpoint ${statement.id} response_container must be none for no-body responses`, fieldMap.get("response_container")?.[0]?.loc || statement.loc); | ||
| } | ||
| if (result === "none" && symbolValue(getFieldValue(statement, "response_entity"))) { | ||
| pushError(errors, `Endpoint ${statement.id} response_entity is not allowed for no-body responses`, fieldMap.get("response_entity")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @returns {void} | ||
| */ | ||
| export function validateSection(errors, statement, fieldMap) { | ||
| if (statement.kind !== "section") { | ||
| return; | ||
| } | ||
| const kind = symbolValue(getFieldValue(statement, "kind")); | ||
| if (kind && !UI_SECTION_KINDS.has(kind)) { | ||
| pushError(errors, `Section ${statement.id} has invalid kind '${kind}'`, fieldMap.get("kind")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {TopogramToken | null | undefined} token | ||
| * @returns {string | null} | ||
| */ | ||
| export function pathValue(token) { | ||
| return token && (token.type === "string" || token.type === "symbol") ? token.value : null; | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @param {TopogramRegistry} registry | ||
| * @param {string} fieldName | ||
| * @param {string} expectedKind | ||
| * @returns {void} | ||
| */ | ||
| function validateRef(errors, statement, fieldMap, registry, fieldName, expectedKind) { | ||
| const id = symbolValue(getFieldValue(statement, fieldName)); | ||
| if (!id) return; | ||
| const target = registry.get(id); | ||
| if (!target) { | ||
| pushError(errors, `${titleFor(statement.kind)} ${statement.id} references missing ${expectedKind} '${id}' for '${fieldName}'`, fieldMap.get(fieldName)?.[0]?.loc || statement.loc); | ||
| } else if (target.kind !== expectedKind) { | ||
| pushError(errors, `${titleFor(statement.kind)} ${statement.id} must reference a ${expectedKind} for '${fieldName}', found ${target.kind} '${target.id}'`, fieldMap.get(fieldName)?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {string} kind | ||
| * @returns {string} | ||
| */ | ||
| function titleFor(kind) { | ||
| return kind === "endpoint" ? "Endpoint" : "Navpoint"; | ||
| } |
| // @ts-check | ||
| import { IDENTIFIER_PATTERN } from "./kinds.js"; | ||
| import { pushError } from "./utils.js"; | ||
| export const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; | ||
| export const HTTP_MEDIA_TYPE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/; | ||
| export const HTTP_GENERATED_PATH_PATTERN = /^\/[^\s"'`\\\x00-\x1F\x7F]*$/; | ||
| export const DOWNLOAD_FILENAME_PATTERN = /^[^/\\\x00-\x1F\x7F]+$/; | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {string} label | ||
| * @param {string | null | undefined} value | ||
| * @param {TopogramLocation | null | undefined} loc | ||
| * @returns {void} | ||
| */ | ||
| export function validatePortableIdentifier(errors, label, value, loc) { | ||
| if (!value || !IDENTIFIER_PATTERN.test(value)) { | ||
| pushError(errors, `${label} '${value || ""}' must match ${IDENTIFIER_PATTERN.source}`, loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {string} label | ||
| * @param {string | null | undefined} value | ||
| * @param {TopogramLocation | null | undefined} loc | ||
| * @returns {void} | ||
| */ | ||
| export function validateHttpHeaderName(errors, label, value, loc) { | ||
| if (value && !HTTP_HEADER_NAME_PATTERN.test(value)) { | ||
| pushError(errors, `${label} '${value}' must be an RFC token-style HTTP header name`, loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {string} label | ||
| * @param {string | null | undefined} value | ||
| * @param {TopogramLocation | null | undefined} loc | ||
| * @returns {void} | ||
| */ | ||
| export function validateGeneratedHttpPath(errors, label, value, loc) { | ||
| if (value && !HTTP_GENERATED_PATH_PATTERN.test(value)) { | ||
| pushError(errors, `${label} '${value}' must be an absolute generated HTTP path without whitespace, controls, quotes, backticks, or backslashes`, loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {string} label | ||
| * @param {string | null | undefined} value | ||
| * @param {TopogramLocation | null | undefined} loc | ||
| * @returns {void} | ||
| */ | ||
| export function validateDownloadMediaType(errors, label, value, loc) { | ||
| if (value && !HTTP_MEDIA_TYPE_PATTERN.test(value)) { | ||
| pushError(errors, `${label} '${value}' must be a simple type/subtype media type token`, loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {string} label | ||
| * @param {string | null | undefined} value | ||
| * @param {TopogramLocation | null | undefined} loc | ||
| * @returns {void} | ||
| */ | ||
| export function validateDownloadFilename(errors, label, value, loc) { | ||
| if (value && (!DOWNLOAD_FILENAME_PATTERN.test(value) || value === "." || value === "..")) { | ||
| pushError(errors, `${label} '${value}' must be a filename without path separators or control characters`, loc); | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "@topogram/cli", | ||
| "version": "0.3.120", | ||
| "version": "0.3.121", | ||
| "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.", | ||
@@ -5,0 +5,0 @@ "license": "Apache-2.0", |
+130
-62
@@ -97,2 +97,56 @@ // @ts-check | ||
| /** | ||
| * @param {string} projectRoot | ||
| * @returns {Set<string>} | ||
| */ | ||
| function packageScriptNames(projectRoot) { | ||
| const packagePath = path.join(projectRoot, "package.json"); | ||
| if (!fs.existsSync(packagePath)) return new Set(); | ||
| try { | ||
| const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); | ||
| return new Set(Object.keys(pkg?.scripts || {})); | ||
| } catch { | ||
| return new Set(); | ||
| } | ||
| } | ||
| /** | ||
| * @param {string} projectRoot | ||
| * @param {string} args | ||
| * @returns {string} | ||
| */ | ||
| function topogramCommand(projectRoot, args) { | ||
| return fs.existsSync(path.join(projectRoot, "engine", "src", "cli.js")) | ||
| ? `node ./engine/src/cli.js ${args}` | ||
| : `topogram ${args}`; | ||
| } | ||
| /** | ||
| * @param {string} projectRoot | ||
| * @param {Set<string>} scripts | ||
| * @param {string} scriptName | ||
| * @param {string} fallbackArgs | ||
| * @param {string} reason | ||
| * @param {string} [phase] | ||
| * @param {string} [scriptArgs] | ||
| * @returns {AgentBriefCommand} | ||
| */ | ||
| function commandFor(projectRoot, scripts, scriptName, fallbackArgs, reason, phase = "first-run", scriptArgs = "") { | ||
| const command = scripts.has(scriptName) | ||
| ? `npm run ${scriptName}${scriptArgs ? ` -- ${scriptArgs}` : ""}` | ||
| : topogramCommand(projectRoot, fallbackArgs); | ||
| return commandItem(command, reason, phase); | ||
| } | ||
| /** | ||
| * @param {Set<string>} scripts | ||
| * @param {string} scriptName | ||
| * @param {string} reason | ||
| * @param {string} [phase] | ||
| * @returns {AgentBriefCommand|null} | ||
| */ | ||
| function packageCommandIfAvailable(scripts, scriptName, reason, phase = "verify") { | ||
| return scripts.has(scriptName) ? commandItem(`npm run ${scriptName}`, reason, phase) : null; | ||
| } | ||
| /** | ||
| * @param {Record<string, any>} config | ||
@@ -187,5 +241,7 @@ * @returns {AgentBriefRuntime[]} | ||
| * @param {boolean} hasImportRecord | ||
| * @param {string} projectRoot | ||
| * @param {Set<string>} scripts | ||
| * @returns {AgentBriefWorkflow[]} | ||
| */ | ||
| function buildWorkflows(config, hasImportRecord) { | ||
| function buildWorkflows(config, hasImportRecord, projectRoot, scripts) { | ||
| const workflows = [ | ||
@@ -196,14 +252,15 @@ { | ||
| commands: [ | ||
| "topogram query sdlc-grooming ./topo --json", | ||
| "topogram query sdlc-available ./topo --json", | ||
| "topogram query sdlc-ready ./topo --json", | ||
| "topogram sdlc start <task-id> --actor <actor> --json", | ||
| "topogram sdlc explain <task-or-bug-id> --json", | ||
| "topogram query slice ./topo --task <task-id> --json", | ||
| "topogram query sdlc-proof-gaps ./topo --task <task-id> --json", | ||
| "topogram query verification-runs ./topo --task <task-id> --json", | ||
| "topogram sdlc plan explain <plan-id> --json", | ||
| "topogram sdlc plan step complete <plan-id> <step-id> --actor <actor> --write" | ||
| topogramCommand(projectRoot, "query sdlc-grooming ./topo --json"), | ||
| topogramCommand(projectRoot, "query sdlc-available ./topo --json"), | ||
| topogramCommand(projectRoot, "query sdlc-ready ./topo --json"), | ||
| topogramCommand(projectRoot, "sdlc start <task-id> --actor <actor> --json"), | ||
| topogramCommand(projectRoot, "sdlc explain <task-or-bug-id> --json"), | ||
| topogramCommand(projectRoot, "work next ./topo --task <task-id> --mode implementation --json"), | ||
| topogramCommand(projectRoot, "query slice ./topo --task <task-id> --json"), | ||
| topogramCommand(projectRoot, "query sdlc-proof-gaps ./topo --task <task-id> --json"), | ||
| topogramCommand(projectRoot, "query verification-runs ./topo --task <task-id> --json"), | ||
| topogramCommand(projectRoot, "sdlc plan explain <plan-id> --json"), | ||
| topogramCommand(projectRoot, "sdlc plan step complete <plan-id> <step-id> --actor <actor> --write") | ||
| ], | ||
| rule: "Start from an SDLC task packet before implementation. Plans are optional; edit plan text directly, but use CLI for status, history, step progress, and archive state." | ||
| rule: "Start from an SDLC task packet, then use work next as the canonical next-action packet before app implementation. Plans are optional; edit plan text directly, but use CLI for status, history, step progress, and archive state." | ||
| }, | ||
@@ -214,8 +271,10 @@ { | ||
| commands: [ | ||
| "npm run agent:brief", | ||
| "npm run check", | ||
| "npm run generate", | ||
| "npm run verify" | ||
| ], | ||
| rule: "Edit the Topogram first, then regenerate generated-owned outputs." | ||
| commandFor(projectRoot, scripts, "onboard", "onboard . --json", "Review the staged adoption loop.").command, | ||
| commandFor(projectRoot, scripts, "agent:brief", "agent brief . --json", "Read the current agent brief.").command, | ||
| topogramCommand(projectRoot, "query modeling-guide ./topo --format markdown"), | ||
| commandFor(projectRoot, scripts, "check", "check", "Validate the Topogram workspace.").command, | ||
| commandFor(projectRoot, scripts, "generate", "generate", "Write generated-owned outputs.").command, | ||
| packageCommandIfAvailable(scripts, "verify", "Run project verification.")?.command | ||
| ].filter(Boolean), | ||
| rule: "Use modeling-guide's phase order before broad DSL authoring; edit the Topogram first, then regenerate generated-owned outputs." | ||
| }, | ||
@@ -226,7 +285,7 @@ { | ||
| commands: [ | ||
| "topogram query list --json", | ||
| "topogram query show widget-behavior", | ||
| "topogram widget check --json", | ||
| "topogram widget behavior --json", | ||
| "topogram emit ui-widget-contract --json" | ||
| topogramCommand(projectRoot, "query list --json"), | ||
| topogramCommand(projectRoot, "query show widget-behavior"), | ||
| topogramCommand(projectRoot, "widget check --json"), | ||
| topogramCommand(projectRoot, "widget behavior --json"), | ||
| topogramCommand(projectRoot, "emit ui-widget-contract --json") | ||
| ], | ||
@@ -239,6 +298,6 @@ rule: "Use focused widget and surface packets before editing UI code." | ||
| commands: [ | ||
| "npm run source:status", | ||
| "npm run template:explain", | ||
| "npm run template:update:recommend", | ||
| "npm run template:update:check" | ||
| commandFor(projectRoot, scripts, "source:status", "source status --local", "Review local template-derived changes.").command, | ||
| commandFor(projectRoot, scripts, "template:explain", "template explain", "Understand template attachment.").command, | ||
| commandFor(projectRoot, scripts, "template:update:recommend", "template update --recommend", "Review update recommendations.").command, | ||
| commandFor(projectRoot, scripts, "template:update:check", "template update --check", "Check candidate template updates.").command | ||
| ], | ||
@@ -253,6 +312,6 @@ rule: "Local Topogram files are project-owned after edits; review update plans before applying template changes." | ||
| commands: [ | ||
| "npm run trust:status", | ||
| "npm run trust:diff", | ||
| "npm run template:policy:explain", | ||
| "topogram trust template" | ||
| commandFor(projectRoot, scripts, "trust:status", "trust status", "Check executable implementation trust.").command, | ||
| commandFor(projectRoot, scripts, "trust:diff", "trust diff", "Review implementation trust drift.").command, | ||
| commandFor(projectRoot, scripts, "template:policy:explain", "template policy explain", "Read executable template policy.").command, | ||
| topogramCommand(projectRoot, "trust template") | ||
| ], | ||
@@ -267,7 +326,7 @@ rule: "Review implementation code before refreshing trust. The brief does not execute implementation providers." | ||
| commands: [ | ||
| "topogram extract check . --json", | ||
| "topogram extract plan . --json", | ||
| "topogram adopt --list . --json", | ||
| "topogram extract status . --json", | ||
| "topogram extract history . --verify --json" | ||
| topogramCommand(projectRoot, "extract check . --json"), | ||
| topogramCommand(projectRoot, "extract plan . --json"), | ||
| topogramCommand(projectRoot, "adopt --list . --json"), | ||
| topogramCommand(projectRoot, "extract status . --json"), | ||
| topogramCommand(projectRoot, "extract history . --verify --json") | ||
| ], | ||
@@ -345,2 +404,3 @@ rule: "Extracted Topogram files are editable after adoption; JSON automation should read workspaceRoot for the project-owned workspace path." | ||
| const configDir = projectConfigInfo?.configDir || projectRoot; | ||
| const scripts = packageScriptNames(projectRoot); | ||
| const template = config.template || {}; | ||
@@ -397,30 +457,33 @@ const trust = config.implementation | ||
| const firstCommands = [ | ||
| commandItem("npm run agent:brief", "Machine-readable current onboarding guidance."), | ||
| commandItem("npm run doctor", "Check local CLI, package, and catalog setup."), | ||
| commandItem("npm run source:status", "See whether template-derived files diverged locally."), | ||
| commandItem("npm run template:explain", "Understand whether the project is template-attached or detached."), | ||
| commandItem("npm run generator:policy:check", "Validate package-backed generator policy before generation."), | ||
| commandFor(projectRoot, scripts, "onboard", "onboard . --json", "Review the staged init/check/audit-bundle/generate/verify adoption loop."), | ||
| commandFor(projectRoot, scripts, "agent:brief", "agent brief . --json", "Machine-readable current onboarding guidance."), | ||
| commandFor(projectRoot, scripts, "doctor", "doctor", "Check local CLI, package, and catalog setup."), | ||
| commandFor(projectRoot, scripts, "source:status", "source status --local", "See whether template-derived files diverged locally."), | ||
| commandFor(projectRoot, scripts, "template:explain", "template explain", "Understand whether the project is template-attached or detached."), | ||
| commandFor(projectRoot, scripts, "generator:policy:check", "generator policy check", "Validate package-backed generator policy before generation."), | ||
| ...(sdlcPolicy.status === "adopted" ? [ | ||
| commandItem("topogram sdlc policy explain --json", "Read current SDLC profile, enforcement mode, obligations, and protected paths.", "sdlc"), | ||
| commandItem("topogram query sdlc-available ./topo --json", "Find unclaimed tasks, unresolved bugs, and approved requirements needing a task.", "sdlc"), | ||
| commandItem("topogram sdlc start <task-id> --actor actor_coding_agent --json", "Read blockers, rules, decisions, proof gaps, and verification guidance before implementation.", "sdlc"), | ||
| commandItem("topogram sdlc gate . --require-adopted --json", "Verify protected work has SDLC linkage before PR/CI.", "sdlc"), | ||
| commandItem("topogram sdlc explain <task-or-bug-id> --json", "Start SDLC-backed implementation from the current task or bug.", "sdlc") | ||
| commandItem(topogramCommand(projectRoot, "sdlc policy explain --json"), "Read current SDLC profile, enforcement mode, obligations, and protected paths.", "sdlc"), | ||
| commandItem(topogramCommand(projectRoot, "query sdlc-available ./topo --json"), "Find unclaimed tasks, unresolved bugs, and approved requirements needing a task.", "sdlc"), | ||
| commandItem(topogramCommand(projectRoot, "sdlc start <task-id> --actor actor_coding_agent --json"), "Read blockers, rules, decisions, proof gaps, and verification guidance before implementation.", "sdlc"), | ||
| commandItem(topogramCommand(projectRoot, "sdlc gate . --require-adopted --json"), "Verify protected work has SDLC linkage before PR/CI.", "sdlc"), | ||
| commandItem(topogramCommand(projectRoot, "sdlc explain <task-or-bug-id> --json"), "Start SDLC-backed implementation from the current task or bug.", "sdlc") | ||
| ] : []), | ||
| ...(config.implementation ? [ | ||
| commandItem("npm run trust:status", "Check executable implementation trust before generation.", "trust") | ||
| commandFor(projectRoot, scripts, "trust:status", "trust status", "Check executable implementation trust before generation.", "trust") | ||
| ] : []), | ||
| commandItem("npm run check", "Validate Topogram, project config, topology, ownership, trust, and generator policy."), | ||
| commandItem("npm run query:list", "Discover focused agent packets."), | ||
| commandItem("npm run query:show -- widget-behavior", "Read a focused UI/widget packet before UI work.", "focused-context"), | ||
| commandItem("npm run generate", "Write generated-owned runtime/app outputs after validation.", "write"), | ||
| commandItem("npm run verify", "Run generated output verification.", "verify"), | ||
| commandFor(projectRoot, scripts, "check", "check", "Validate Topogram, project config, topology, ownership, trust, and generator policy.", "verify"), | ||
| commandFor(projectRoot, scripts, "query:list", "query list", "Discover focused agent packets."), | ||
| commandItem(topogramCommand(projectRoot, "work next ./topo --task <task-id> --mode implementation --json"), "Get the canonical next-action implementation packet for the current task.", "work-next"), | ||
| commandItem(topogramCommand(projectRoot, "query modeling-guide ./topo --format markdown"), "Read current DSL authoring guidance and the recommended modeling phase order before broad Topogram modeling.", "modeling-guide"), | ||
| commandFor(projectRoot, scripts, "query:show", "query show widget-behavior", "Read a focused UI/widget packet before UI work.", "focused-context", "widget-behavior"), | ||
| commandFor(projectRoot, scripts, "generate", "generate", "Write generated-owned runtime/app outputs after validation.", "write"), | ||
| packageCommandIfAvailable(scripts, "verify", "Run generated output verification.", "verify"), | ||
| ...(importSummary ? [ | ||
| commandItem("topogram extract check . --json", "Validate extracted workspace provenance and read workspaceRoot.", "extract"), | ||
| commandItem("topogram extract plan . --json", "Review extraction adoption plan and workspaceRoot.", "extract"), | ||
| commandItem("topogram adopt --list . --json", "List reviewable adoption selectors.", "adopt"), | ||
| commandItem("topogram extract status . --json", "Check extraction/adoption status.", "extract"), | ||
| commandItem("topogram extract history . --verify --json", "Verify adoption history evidence.", "extract") | ||
| commandItem(topogramCommand(projectRoot, "extract check . --json"), "Validate extracted workspace provenance and read workspaceRoot.", "extract"), | ||
| commandItem(topogramCommand(projectRoot, "extract plan . --json"), "Review extraction adoption plan and workspaceRoot.", "extract"), | ||
| commandItem(topogramCommand(projectRoot, "adopt --list . --json"), "List reviewable adoption selectors.", "adopt"), | ||
| commandItem(topogramCommand(projectRoot, "extract status . --json"), "Check extraction/adoption status.", "extract"), | ||
| commandItem(topogramCommand(projectRoot, "extract history . --verify --json"), "Verify adoption history evidence.", "extract") | ||
| ] : []) | ||
| ]; | ||
| ].filter(Boolean); | ||
@@ -472,3 +535,3 @@ const generatorPolicy = { | ||
| }, | ||
| workflows: buildWorkflows(config, Boolean(importSummary)), | ||
| workflows: buildWorkflows(config, Boolean(importSummary), projectRoot, scripts), | ||
| file_organization: { | ||
@@ -508,2 +571,4 @@ small: [`${DEFAULT_TOPO_FOLDER_NAME}/actors`, `${DEFAULT_TOPO_FOLDER_NAME}/entities`, `${DEFAULT_TOPO_FOLDER_NAME}/shapes`, `${DEFAULT_TOPO_FOLDER_NAME}/capabilities`, `${DEFAULT_TOPO_FOLDER_NAME}/widgets`, `${DEFAULT_TOPO_FOLDER_NAME}/surfaces`, `${DEFAULT_TOPO_FOLDER_NAME}/verifications`], | ||
| const lines = []; | ||
| const firstCommands = brief.first_commands || []; | ||
| const findCommand = (phase, fallback) => firstCommands.find((item) => item.phase === phase)?.command || fallback; | ||
| lines.push("Topogram agent brief"); | ||
@@ -560,3 +625,3 @@ lines.push(`Project: ${brief.project?.root || "unknown"}`); | ||
| lines.push("Verification gates:"); | ||
| lines.push(" - npm run check"); | ||
| lines.push(` - ${findCommand("verify", "topogram check")}`); | ||
| if (brief.sdlc_policy?.status === "adopted") { | ||
@@ -567,4 +632,7 @@ lines.push(` - ${brief.sdlc_policy.gateCommand || "topogram sdlc gate . --require-adopted"}`); | ||
| lines.push(" - topogram widget behavior --json when widget behavior changes"); | ||
| lines.push(" - npm run generate"); | ||
| lines.push(" - npm run verify"); | ||
| lines.push(` - ${findCommand("write", "topogram generate")}`); | ||
| const packageVerify = firstCommands.find((item) => item.command === "npm run verify")?.command; | ||
| if (packageVerify) { | ||
| lines.push(` - ${packageVerify}`); | ||
| } | ||
| if ((brief.warnings || []).length > 0) { | ||
@@ -571,0 +639,0 @@ lines.push(""); |
@@ -271,2 +271,18 @@ import { | ||
| const mode = taskModeArtifact?.mode || null; | ||
| if (mode === "modeling") { | ||
| return [ | ||
| { | ||
| order: 1, | ||
| action: "read_modeling_guide", | ||
| artifact: "topogram query modeling-guide ./topo --mode greenfield-app --format markdown", | ||
| review_required: false, | ||
| reason: "Use the CLI-native phase order before broad DSL authoring." | ||
| }, | ||
| ...buildGenericSequence(taskModeArtifact, { | ||
| readReason: "Start from the focused modeling context after reading the modeling guide.", | ||
| reviewReason: "Review semantic boundaries before changing durable intent.", | ||
| proofReason: "Run topogram check and the smallest proof set attached to this mode." | ||
| }).map((step) => ({ ...step, order: step.order + 1 })) | ||
| ]; | ||
| } | ||
| if (mode === "extract-adopt") { | ||
@@ -273,0 +289,0 @@ return buildImportAdoptSequence(taskModeArtifact, importPlan); |
@@ -363,2 +363,21 @@ import { | ||
| function modelingGuidanceSummary(mode) { | ||
| if (mode !== "modeling") return null; | ||
| const recommendedAuthoringOrder = [ | ||
| "feature_scope", | ||
| "entities_and_rules", | ||
| "capabilities_and_persistence", | ||
| "endpoints_and_seed_data", | ||
| "navpoints_screens_and_journeys", | ||
| "verification", | ||
| "implementation_entry" | ||
| ]; | ||
| return { | ||
| command: "topogram query modeling-guide ./topo --mode greenfield-app --format markdown", | ||
| recommended_authoring_order: recommendedAuthoringOrder, | ||
| next_implementation_packet: "topogram query slice ./topo --mode implementation --task <task-id> --detail compact --json", | ||
| rule: "Model the smallest valid current-feature slice first, run topogram check, then switch to an implementation packet." | ||
| }; | ||
| } | ||
| export function buildSingleAgentPlanPayload({ | ||
@@ -395,2 +414,3 @@ workspace, | ||
| recommended_sequence: buildRecommendedSequence(taskModeArtifact, importPlan), | ||
| modeling_guidance: modelingGuidanceSummary(taskModeArtifact?.mode || null), | ||
| blocking_conditions: buildBlockingConditions(taskModeArtifact, importPlan), | ||
@@ -397,0 +417,0 @@ primary_artifacts: primaryArtifacts, |
@@ -9,3 +9,3 @@ // @ts-check | ||
| import { catalogEntryPackageSpec } from "./entries.js"; | ||
| import { copyPath, ensureEmptyDirectory } from "./files.js"; | ||
| import { assertTopogramCopySourceHasNoSymlinks, copyPath, ensureEmptyDirectory } from "./files.js"; | ||
| import { writeTopogramSourceRecord } from "./provenance.js"; | ||
@@ -31,3 +31,10 @@ | ||
| } | ||
| for (const fileName of ["topogram.project.json", "README.md"]) { | ||
| const sourcePath = path.join(packageRoot, fileName); | ||
| if (fs.existsSync(sourcePath)) { | ||
| assertTopogramCopySourceHasNoSymlinks(sourcePath, fileName); | ||
| } | ||
| } | ||
| const packageWorkspace = resolvePackageWorkspace(packageRoot); | ||
| assertTopogramCopySourceHasNoSymlinks(packageWorkspace.root, DEFAULT_TOPO_FOLDER_NAME); | ||
@@ -34,0 +41,0 @@ const resolvedTarget = path.resolve(targetPath); |
+33
-0
@@ -8,2 +8,35 @@ // @ts-check | ||
| /** | ||
| * @param {string} relativePath | ||
| * @returns {string} | ||
| */ | ||
| export function unsupportedTopogramCopySymlinkMessage(relativePath) { | ||
| return `Topogram copy source contains unsupported symlink '${relativePath}'. Topogram source packages must copy real files because Topogram records source hashes and provenance; replace the symlink with a real file or directory before running topogram copy.`; | ||
| } | ||
| /** | ||
| * @param {string} sourcePath | ||
| * @param {string} relativePath | ||
| * @returns {void} | ||
| */ | ||
| export function assertTopogramCopySourceHasNoSymlinks(sourcePath, relativePath) { | ||
| const stat = fs.lstatSync(sourcePath); | ||
| if (stat.isSymbolicLink()) { | ||
| throw new Error(unsupportedTopogramCopySymlinkMessage(relativePath.replace(/\\/g, "/"))); | ||
| } | ||
| if (!stat.isDirectory()) { | ||
| return; | ||
| } | ||
| for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) { | ||
| const childPath = path.join(sourcePath, entry.name); | ||
| const childRelativePath = path.join(relativePath, entry.name); | ||
| if (entry.isSymbolicLink()) { | ||
| throw new Error(unsupportedTopogramCopySymlinkMessage(childRelativePath.replace(/\\/g, "/"))); | ||
| } | ||
| if (entry.isDirectory()) { | ||
| assertTopogramCopySourceHasNoSymlinks(childPath, childRelativePath); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @param {string} currentPath | ||
@@ -10,0 +43,0 @@ * @param {string} relativePath |
@@ -18,6 +18,16 @@ // @ts-check | ||
| const tokenHosts = new Set(["github.com", "api.github.com", "raw.githubusercontent.com"]); | ||
| function tokenAllowed(url) { | ||
| function tokenHostAllowed(url) { | ||
| const hostname = new URL(url).hostname.toLowerCase(); | ||
| return tokenHosts.has(hostname) || hostname.endsWith(".github.com"); | ||
| } | ||
| function tokenAllowed(url) { | ||
| const parsed = new URL(url); | ||
| return parsed.protocol === "https:" && tokenHostAllowed(url); | ||
| } | ||
| function assertSafeTokenUrl(url) { | ||
| const parsed = new URL(url); | ||
| if (token && tokenHostAllowed(url) && parsed.protocol !== "https:") { | ||
| throw new Error("Refusing to use GitHub catalog token with non-HTTPS URL: " + url); | ||
| } | ||
| } | ||
| async function readResponseText(response, url) { | ||
@@ -60,2 +70,3 @@ const declaredLength = Number.parseInt(response.headers.get("content-length") || "", 10); | ||
| } | ||
| assertSafeTokenUrl(url); | ||
| if (process.env.TOPOGRAM_CATALOG_URL_FIXTURE_PATH) { | ||
@@ -77,2 +88,5 @@ const fs = await import("node:fs"); | ||
| const next = new URL(response.headers.get("location"), url).toString(); | ||
| if (tokenAllowed(url) && new URL(next).protocol !== "https:") { | ||
| throw new Error("Refusing GitHub catalog token redirect to non-HTTPS URL: " + next); | ||
| } | ||
| return readUrl(next, redirects + 1); | ||
@@ -173,2 +187,3 @@ } | ||
| ); | ||
| assertSafeCatalogTokenUrl(source, token); | ||
| const tokenEnv = token && githubTokenAllowedForCatalogUrl(source) | ||
@@ -200,3 +215,7 @@ ? { TOPOGRAM_FETCH_TOKEN: token } | ||
| try { | ||
| const hostname = new URL(source).hostname.toLowerCase(); | ||
| const parsed = new URL(source); | ||
| const hostname = parsed.hostname.toLowerCase(); | ||
| if (parsed.protocol !== "https:") { | ||
| return false; | ||
| } | ||
| return GITHUB_TOKEN_HOSTS.has(hostname) || hostname.endsWith(".github.com"); | ||
@@ -207,1 +226,24 @@ } catch { | ||
| } | ||
| /** | ||
| * @param {string} source | ||
| * @param {string} token | ||
| * @returns {void} | ||
| */ | ||
| function assertSafeCatalogTokenUrl(source, token) { | ||
| if (!token) { | ||
| return; | ||
| } | ||
| try { | ||
| const parsed = new URL(source); | ||
| const hostname = parsed.hostname.toLowerCase(); | ||
| const isGithubTokenHost = GITHUB_TOKEN_HOSTS.has(hostname) || hostname.endsWith(".github.com"); | ||
| if (isGithubTokenHost && parsed.protocol !== "https:") { | ||
| throw new Error(`Refusing to use GitHub catalog token with non-HTTPS URL '${source}'. Use https:// for token-authenticated GitHub catalog sources.`); | ||
| } | ||
| } catch (error) { | ||
| if (error instanceof Error && error.message.startsWith("Refusing to use GitHub catalog token")) { | ||
| throw error; | ||
| } | ||
| } | ||
| } |
@@ -9,2 +9,6 @@ // @ts-check | ||
| "slice", | ||
| "context-savings", | ||
| "implementation-prep", | ||
| "repair-model", | ||
| "modeling-guide", | ||
| "adoption-plan", | ||
@@ -68,2 +72,17 @@ "maintained-boundary", | ||
| } | ||
| if (args[0] === "onboard") { | ||
| return { onboardCommand: true, inputPath: commandPath(args, 1, ".") }; | ||
| } | ||
| if (args[0] === "feature" && args[1] === "new") { | ||
| return { featureCommand: "new", featureSlug: args[2], inputPath: commandPath(args, 3, ".") }; | ||
| } | ||
| if (args[0] === "work" && ["next", "advance"].includes(args[1])) { | ||
| return { workCommand: args[1], inputPath: commandPath(args, 2, "./topo") }; | ||
| } | ||
| if (args[0] === "trace" && ["analyze", "report"].includes(args[1])) { | ||
| return { traceCommand: args[1], inputPath: commandPath(args, 2, ".") }; | ||
| } | ||
| if (args[0] === "trace" && args[1] === "compare") { | ||
| return { traceCommand: "compare", inputPath: commandPath(args, 2, "."), comparePath: commandPath(args, 3, ".") }; | ||
| } | ||
| if (args[0] === "generate" && args[1] === "app") { | ||
@@ -70,0 +89,0 @@ return { generateTarget: "app-bundle", write: true, inputPath: commandPath(args, 2), defaultOutDir: "./app" }; |
@@ -32,2 +32,8 @@ // @ts-check | ||
| } | ||
| if (args[0] === "sdlc" && args[1] === "verify" && args[2] === "record-batch") { | ||
| return { | ||
| sdlcCommand: "verify:record-batch", | ||
| inputPath: commandPath(args, 3, ".") | ||
| }; | ||
| } | ||
| if (args[0] === "sdlc" && args[1] === "link") { | ||
@@ -34,0 +40,0 @@ return { |
@@ -11,20 +11,47 @@ // @ts-check | ||
| * @param {string|null} source | ||
| * @param {{ graceful?: boolean }} [options] | ||
| * @returns {{ ok: boolean, source: string, catalog: any, entries: any[], templates: any[], topograms: any[], diagnostics: any[], errors: string[] }} | ||
| */ | ||
| export function buildCatalogListPayload(source) { | ||
| const loaded = loadCatalog(source || null); | ||
| return { | ||
| ok: true, | ||
| source: loaded.source, | ||
| catalog: { | ||
| loaded: true, | ||
| version: loaded.catalog.version, | ||
| entries: loaded.catalog.entries.length | ||
| }, | ||
| entries: loaded.catalog.entries, | ||
| templates: loaded.catalog.entries.filter((/** @type {any} */ entry) => entry.kind === "template"), | ||
| topograms: loaded.catalog.entries.filter((/** @type {any} */ entry) => entry.kind === "topogram"), | ||
| diagnostics: loaded.diagnostics, | ||
| errors: [] | ||
| }; | ||
| export function buildCatalogListPayload(source, options = {}) { | ||
| try { | ||
| const loaded = loadCatalog(source || null); | ||
| return { | ||
| ok: true, | ||
| source: loaded.source, | ||
| catalog: { | ||
| loaded: true, | ||
| version: loaded.catalog.version, | ||
| entries: loaded.catalog.entries.length | ||
| }, | ||
| entries: loaded.catalog.entries, | ||
| templates: loaded.catalog.entries.filter((/** @type {any} */ entry) => entry.kind === "template"), | ||
| topograms: loaded.catalog.entries.filter((/** @type {any} */ entry) => entry.kind === "topogram"), | ||
| diagnostics: loaded.diagnostics, | ||
| errors: [] | ||
| }; | ||
| } catch (error) { | ||
| if (!options.graceful) throw error; | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| const resolvedSource = source || catalogSourceOrDefault(null); | ||
| return { | ||
| ok: true, | ||
| source: resolvedSource, | ||
| catalog: { | ||
| loaded: false, | ||
| version: null, | ||
| entries: 0 | ||
| }, | ||
| entries: [], | ||
| templates: [], | ||
| topograms: [], | ||
| diagnostics: [{ | ||
| code: "catalog_unavailable", | ||
| severity: "warning", | ||
| message, | ||
| source: resolvedSource, | ||
| suggestedFix: "Pass --catalog <local-file> or set TOPOGRAM_CATALOG_SOURCE=none for offline use." | ||
| }], | ||
| errors: [] | ||
| }; | ||
| } | ||
| } | ||
@@ -39,4 +66,4 @@ | ||
| console.log("Template entries create starters with `topogram copy`; topogram entries copy editable Topogram source."); | ||
| console.log(`Catalog: ${payload.source}`); | ||
| console.log(`Version: ${payload.catalog.version}`); | ||
| console.log(`Catalog: ${payload.source} (${payload.catalog.loaded ? "loaded" : "unavailable"})`); | ||
| console.log(`Version: ${payload.catalog.version || "unknown"}`); | ||
| const catalogOption = payload.source === catalogSourceOrDefault(null) | ||
@@ -57,2 +84,5 @@ ? "" | ||
| } | ||
| for (const diagnostic of payload.diagnostics || []) { | ||
| console.warn(`Warning: ${diagnostic.message}`); | ||
| } | ||
| } |
@@ -73,3 +73,5 @@ // @ts-check | ||
| .sort((left, right) => left.name.localeCompare(right.name)); | ||
| const runtimeInputs = /** @type {Array<AnyRecord>} */ (config?.topology?.runtimes || []); | ||
| const runtimeInputs = Array.isArray(config?.topology?.runtimes) | ||
| ? /** @type {Array<AnyRecord>} */ (config.topology.runtimes) | ||
| : []; | ||
| const runtimes = runtimeInputs | ||
@@ -133,3 +135,3 @@ .map((component) => ({ | ||
| } | ||
| const runtimes = (topology.runtimes || []).map((/** @type {AnyRecord} */ runtime) => { | ||
| const runtimes = (Array.isArray(topology.runtimes) ? topology.runtimes : []).map((/** @type {AnyRecord} */ runtime) => { | ||
| const publicRuntime = { ...runtime }; | ||
@@ -270,6 +272,5 @@ if (publicRuntime.projection != null && publicRuntime.surface == null) { | ||
| * @param {string|null|undefined} inputPath | ||
| * @param {{ json?: boolean }} [options] | ||
| * @returns {Promise<number>} | ||
| * @returns {Promise<{ payload: AnyRecord, ast: AnyRecord, resolved: AnyRecord, projectConfigInfo: AnyRecord|null|undefined, projectValidation: { ok: boolean, errors: ValidationError[], warnings: ValidationError[] }, publicContext: AnyRecord }>} | ||
| */ | ||
| export async function runCheckCommand(inputPath, options = {}) { | ||
| export async function buildCheckCommandPayload(inputPath) { | ||
| const topogramPath = inputPath || "./topo"; | ||
@@ -295,2 +296,13 @@ const ast = parsePath(topogramPath); | ||
| }; | ||
| return { payload, ast, resolved, projectConfigInfo, projectValidation, publicContext }; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} inputPath | ||
| * @param {{ json?: boolean }} [options] | ||
| * @returns {Promise<number>} | ||
| */ | ||
| export async function runCheckCommand(inputPath, options = {}) { | ||
| const { payload, resolved, projectConfigInfo, projectValidation, publicContext } = await buildCheckCommandPayload(inputPath); | ||
| const topogramPath = inputPath || "./topo"; | ||
| if (options.json) { | ||
@@ -297,0 +309,0 @@ console.log(stablePublicStringify(payload, publicContext)); |
@@ -16,3 +16,3 @@ // @ts-check | ||
| import { createNewProject } from "../../new-project.js"; | ||
| import { copyPath, ensureEmptyDirectory } from "../../catalog/files.js"; | ||
| import { assertTopogramCopySourceHasNoSymlinks, copyPath, ensureEmptyDirectory } from "../../catalog/files.js"; | ||
| import { writeTopogramSourceRecord } from "../../catalog/provenance.js"; | ||
@@ -144,3 +144,10 @@ import { DEFAULT_TOPO_FOLDER_NAME, DEFAULT_WORKSPACE_PATH, resolvePackageWorkspace } from "../../workspace-paths.js"; | ||
| } | ||
| for (const fileName of ["topogram.project.json", "README.md"]) { | ||
| const sourceFile = path.join(packageRoot, fileName); | ||
| if (fs.existsSync(sourceFile)) { | ||
| assertTopogramCopySourceHasNoSymlinks(sourceFile, fileName); | ||
| } | ||
| } | ||
| const packageWorkspace = resolvePackageWorkspace(packageRoot); | ||
| assertTopogramCopySourceHasNoSymlinks(packageWorkspace.root, DEFAULT_TOPO_FOLDER_NAME); | ||
| const resolvedTarget = path.resolve(targetPath); | ||
@@ -247,2 +254,3 @@ ensureEmptyDirectory(resolvedTarget); | ||
| console.log(" npm install"); | ||
| console.log(" npm run onboard"); | ||
| console.log(" npm run agent:brief"); | ||
@@ -300,3 +308,3 @@ console.log(" npm run doctor"); | ||
| if (commandArgs.copyCommand === "list") { | ||
| const payload = buildCatalogListPayload(catalogSource || null); | ||
| const payload = buildCatalogListPayload(catalogSource || null, { graceful: true }); | ||
| if (json) { | ||
@@ -303,0 +311,0 @@ console.log(stablePublicStringify(payload, { cwd: process.cwd() })); |
@@ -59,2 +59,4 @@ // @ts-check | ||
| const INCREMENTAL_WRITE_TARGETS = new Set([ | ||
| "audit-bundle", | ||
| "context-slice", | ||
| "glossary", | ||
@@ -85,3 +87,4 @@ "work-map-report" | ||
| * fromTopogramPath?: string|null, | ||
| * checkPath?: string|null | ||
| * checkPath?: string|null, | ||
| * seedFile?: string|null | ||
| * }} options | ||
@@ -132,3 +135,7 @@ * @returns {Promise<number>} | ||
| fromTopogramPath: options.fromTopogramPath, | ||
| seedFile: options.seedFile || null, | ||
| topogramInputPath: topogramInputPathForGeneration(options.inputPath), | ||
| inputPath: options.inputPath, | ||
| workspaceRoot: options.inputPath, | ||
| cwd: process.cwd(), | ||
| implementation, | ||
@@ -145,3 +152,10 @@ projectConfig: projectConfigInfo?.config || null, | ||
| if (options.checkPath) { | ||
| const outputFiles = buildOutputFiles(result, options.outputSelectors || {}); | ||
| const outputFiles = buildOutputFiles(result, { | ||
| ...(options.outputSelectors || {}), | ||
| outputFormat: options.outputFormat, | ||
| projectRoot: projectConfigInfo?.configDir || options.projectRoot, | ||
| workspaceRoot: options.inputPath, | ||
| inputPath: options.inputPath, | ||
| cwd: process.cwd() | ||
| }); | ||
| if (outputFiles.length !== 1) { | ||
@@ -182,3 +196,10 @@ console.error(`Target '${options.target}' writes ${outputFiles.length} files; --check supports single-file artifact targets only.`); | ||
| if (INCREMENTAL_WRITE_TARGETS.has(options.target)) { | ||
| const outputFiles = buildOutputFiles(result, options.outputSelectors || {}); | ||
| const outputFiles = buildOutputFiles(result, { | ||
| ...(options.outputSelectors || {}), | ||
| outputFormat: options.outputFormat, | ||
| projectRoot: projectConfigInfo?.configDir || options.projectRoot, | ||
| workspaceRoot: options.inputPath, | ||
| inputPath: options.inputPath, | ||
| cwd: process.cwd() | ||
| }); | ||
| fs.mkdirSync(resolvedOutDir, { recursive: true }); | ||
@@ -212,3 +233,10 @@ for (const file of outputFiles) { | ||
| assertSafeGeneratedOutputDir(resolvedOutDir, options.inputPath); | ||
| const outputFiles = buildOutputFiles(result, options.outputSelectors || {}); | ||
| const outputFiles = buildOutputFiles(result, { | ||
| ...(options.outputSelectors || {}), | ||
| outputFormat: options.outputFormat, | ||
| projectRoot: projectConfigInfo?.configDir || options.projectRoot, | ||
| workspaceRoot: options.inputPath, | ||
| inputPath: options.inputPath, | ||
| cwd: process.cwd() | ||
| }); | ||
| outputFiles.unshift({ | ||
@@ -250,4 +278,24 @@ path: GENERATED_OUTPUT_SENTINEL, | ||
| } | ||
| if (options.target === "context-slice" && outputFormat === "html") { | ||
| const { sanitizePublicPayload } = await import("../../public-paths.js"); | ||
| const { formatContextSliceHtml } = await import("../../generator/context/slice/html.js"); | ||
| const publicPayload = sanitizePublicPayload(result.artifact, { | ||
| projectRoot: projectConfigInfo?.configDir || options.projectRoot, | ||
| workspaceRoot: options.inputPath, | ||
| topogramRoot: options.inputPath, | ||
| cwd: process.cwd() | ||
| }); | ||
| console.log(formatContextSliceHtml(publicPayload).trimEnd()); | ||
| return 0; | ||
| } | ||
| if (options.target === "audit-bundle") { | ||
| console.log(stablePublicStringify(result.artifact.manifest, { | ||
| projectRoot: projectConfigInfo?.configDir || options.projectRoot, | ||
| workspaceRoot: options.inputPath, | ||
| cwd: process.cwd() | ||
| })); | ||
| return 0; | ||
| } | ||
| if (outputFormat && outputFormat !== "json") { | ||
| console.error(`Unsupported emit output format '${options.outputFormat}'. Use --format markdown for ui-design-coverage/work-map-report or --json.`); | ||
| console.error(`Unsupported emit output format '${options.outputFormat}'. Use --format html for context-slice, --format markdown for ui-design-coverage/work-map-report, or --json.`); | ||
| return 2; | ||
@@ -254,0 +302,0 @@ } |
@@ -35,5 +35,5 @@ // @ts-check | ||
| * }} options | ||
| * @returns {Promise<number>} | ||
| * @returns {Promise<{ outDir: string, filesWritten: number, projectRoot: string, workspaceRoot: string }>} | ||
| */ | ||
| export async function runGenerateAppCommand(options) { | ||
| export async function writeGeneratedAppBundle(options) { | ||
| const ast = parsePath(options.inputPath); | ||
@@ -47,4 +47,3 @@ const explicitProjectConfig = loadProjectConfig(options.projectRoot) || loadProjectConfig(options.inputPath); | ||
| if (!resolvedForConfig.ok) { | ||
| console.error(formatValidationErrors(resolvedForConfig.validation)); | ||
| return 1; | ||
| throw new Error(formatValidationErrors(resolvedForConfig.validation)); | ||
| } | ||
@@ -54,4 +53,3 @@ const defaultProjectConfig = projectConfigOrDefault(options.projectRoot, resolvedForConfig.graph, implementation); | ||
| if (!projectConfigInfo) { | ||
| console.error("Unable to resolve topogram.project.json or derive a default project config."); | ||
| return 1; | ||
| throw new Error("Unable to resolve topogram.project.json or derive a default project config."); | ||
| } | ||
@@ -62,4 +60,3 @@ const projectConfigValidation = validateProjectConfig(projectConfigInfo.config, resolvedForConfig.graph, { | ||
| if (!projectConfigValidation.ok) { | ||
| console.error(formatProjectConfigErrors(projectConfigValidation, projectConfigInfo?.configPath || "topogram.project.json")); | ||
| return 1; | ||
| throw new Error(formatProjectConfigErrors(projectConfigValidation, projectConfigInfo?.configPath || "topogram.project.json")); | ||
| } | ||
@@ -77,4 +74,3 @@ | ||
| if (!result.ok) { | ||
| console.error(formatValidationErrors(result.validation)); | ||
| return 1; | ||
| throw new Error(formatValidationErrors(result.validation)); | ||
| } | ||
@@ -101,8 +97,32 @@ | ||
| console.log(`Wrote ${outputFiles.length} file(s) to ${toPortablePath(resolvedOutDir, { | ||
| return { | ||
| outDir: resolvedOutDir, | ||
| filesWritten: outputFiles.length, | ||
| projectRoot: projectConfigInfo.configDir || options.projectRoot, | ||
| workspaceRoot: options.inputPath, | ||
| cwd: process.cwd() | ||
| })}`); | ||
| return 0; | ||
| workspaceRoot: options.inputPath | ||
| }; | ||
| } | ||
| /** | ||
| * @param {{ | ||
| * inputPath: string, | ||
| * projectRoot: string, | ||
| * outDir?: string|null, | ||
| * profileId?: string|null | ||
| * }} options | ||
| * @returns {Promise<number>} | ||
| */ | ||
| export async function runGenerateAppCommand(options) { | ||
| try { | ||
| const result = await writeGeneratedAppBundle(options); | ||
| console.log(`Wrote ${result.filesWritten} file(s) to ${toPortablePath(result.outDir, { | ||
| projectRoot: result.projectRoot, | ||
| workspaceRoot: result.workspaceRoot, | ||
| cwd: process.cwd() | ||
| })}`); | ||
| return 0; | ||
| } catch (error) { | ||
| console.error(error instanceof Error ? error.message : String(error)); | ||
| return 1; | ||
| } | ||
| } |
@@ -11,3 +11,4 @@ // @ts-check | ||
| * output: string, | ||
| * example: string | ||
| * example: string, | ||
| * public?: boolean | ||
| * }} QueryDefinition | ||
@@ -36,3 +37,3 @@ * | ||
| export function queryDefinitions() { | ||
| const contextSelectors = ["mode", "capability", "workflow", "surface", "screen", "layout", "region", "component-map", "widget", "entity", "journey", "domain", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document", "from-topogram"]; | ||
| const contextSelectors = ["mode", "capability", "workflow", "surface", "screen", "layout", "region", "component-map", "widget", "entity", "journey", "domain", "feature", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document", "from-topogram"]; | ||
| return [ | ||
@@ -42,9 +43,46 @@ { | ||
| purpose: "Give an agent the smallest graph slice needed to reason about one selected semantic surface.", | ||
| description: "Return an attention-guided semantic context slice for one selected surface. Use --detail compact|standard|full to control depth and --format markdown for a human-readable stdout view.", | ||
| selectors: ["capability", "workflow", "surface", "screen", "layout", "region", "component-map", "widget", "entity", "journey", "domain", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document"], | ||
| args: ["[path]", "[selectors]", "[--detail compact|standard|full]", "[--json|--format markdown]"], | ||
| description: "Return an attention-guided semantic context slice for one selected surface. Use --detail compact|standard|full to control depth and --format markdown or --format html for human-readable views.", | ||
| selectors: ["capability", "workflow", "surface", "screen", "layout", "region", "component-map", "widget", "entity", "journey", "domain", "feature", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document"], | ||
| args: ["[path]", "[selectors]", "[--detail compact|standard|full]", "[--json|--format markdown|--format html]"], | ||
| output: "context_slice", | ||
| example: "topogram query slice ./topo --surface proj_web --screen item_list --detail compact --format markdown" | ||
| example: "topogram query slice ./topo --surface proj_web --screen item_list --detail compact --format html" | ||
| }, | ||
| { | ||
| name: "context-savings", | ||
| purpose: "Estimate whether a focused context slice saves agent tokens against broad self-discovery.", | ||
| description: "Return deterministic approximate token counts for the generated context slice, a broad agent-brief plus topo source baseline proxy, and optional transcript-based observed savings.", | ||
| selectors: ["capability", "workflow", "surface", "screen", "layout", "region", "component-map", "widget", "entity", "journey", "domain", "feature", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document"], | ||
| args: ["[path]", "[selectors]", "[--detail compact|standard|full]", "[--transcript <jsonl>]", "[--json|--format markdown]"], | ||
| output: "context_savings_query", | ||
| example: "topogram query context-savings ./topo --task task_example --detail compact --json" | ||
| }, | ||
| { | ||
| name: "implementation-prep", | ||
| purpose: "Legacy internal bridge used by work next.", | ||
| description: "Return the older bucketed implementation preparation packet. Prefer `topogram work next` for agent workflow guidance.", | ||
| selectors: ["capability", "workflow", "surface", "screen", "layout", "region", "component-map", "widget", "entity", "journey", "domain", "feature", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document"], | ||
| args: ["[path]", "[selectors]", "[--detail compact|standard|full]", "[--include-file <path>...]", "[--json]"], | ||
| output: "implementation_prep_query", | ||
| example: "topogram query implementation-prep ./topo --task task_example --detail compact --include-file server.mjs --json", | ||
| public: false | ||
| }, | ||
| { | ||
| name: "repair-model", | ||
| purpose: "Turn invalid Topogram check diagnostics into a grouped model repair packet.", | ||
| description: "Return source-linked repair guidance for invalid Topogram DSL without editing or silently rescuing the model. Works when topogram check fails.", | ||
| selectors: [], | ||
| args: ["[path]", "[--json|--format markdown]"], | ||
| output: "model_repair_query", | ||
| example: "topogram query repair-model ./topo --format markdown" | ||
| }, | ||
| { | ||
| name: "modeling-guide", | ||
| purpose: "Show current product-agnostic DSL authoring guidance before broad model edits.", | ||
| description: "Return a read-only modeling guide for sparse, invalid, or greenfield workspaces, including current-wave-first record examples for navpoints, endpoints, screens, layouts, sections, seed data, journeys, and verification.", | ||
| selectors: [], | ||
| args: ["[path]", "[--mode greenfield-app]", "[--json|--format markdown]"], | ||
| output: "modeling_guide_query", | ||
| example: "topogram query modeling-guide ./topo --mode greenfield-app --format markdown" | ||
| }, | ||
| { | ||
| name: "verification-targets", | ||
@@ -78,3 +116,3 @@ purpose: "Map a selected change or mode to the smallest verification set worth running.", | ||
| name: "work-map", | ||
| purpose: "Show where UI work belongs across surfaces, screens, layouts, regions, widget bindings, widgets, and component maps.", | ||
| purpose: "Show where UI work belongs across navpoints, screens, layouts, regions, render entries, widgets, and component maps.", | ||
| description: "Return a markdown- or JSON-first explorer report for UI work-map nodes, design/style gaps, and exact drill-down proof commands. This is a report surface, not a renderer.", | ||
@@ -147,3 +185,4 @@ selectors: ["surface", "screen", "layout", "region", "component-map", "widget"], | ||
| output: "work_packet", | ||
| example: "topogram query work-packet ./extracted-topogram --mode extract-adopt --lane adoption_operator --json" | ||
| example: "topogram query work-packet ./extracted-topogram --mode extract-adopt --lane adoption_operator --json", | ||
| public: false | ||
| }, | ||
@@ -157,3 +196,4 @@ { | ||
| output: "lane_status_query", | ||
| example: "topogram query lane-status ./extracted-topogram --mode extract-adopt --json" | ||
| example: "topogram query lane-status ./extracted-topogram --mode extract-adopt --json", | ||
| public: false | ||
| }, | ||
@@ -167,3 +207,4 @@ { | ||
| output: "handoff_status_query", | ||
| example: "topogram query handoff-status ./extracted-topogram --mode extract-adopt --json" | ||
| example: "topogram query handoff-status ./extracted-topogram --mode extract-adopt --json", | ||
| public: false | ||
| }, | ||
@@ -300,2 +341,9 @@ { | ||
| /** | ||
| * @returns {QueryDefinition[]} | ||
| */ | ||
| function publicQueryDefinitions() { | ||
| return queryDefinitions().filter((entry) => entry.public !== false); | ||
| } | ||
| /** | ||
| * @returns {QueryListPayload} | ||
@@ -307,3 +355,3 @@ */ | ||
| version: 1, | ||
| queries: queryDefinitions() | ||
| queries: publicQueryDefinitions() | ||
| }; | ||
@@ -319,3 +367,3 @@ } | ||
| if (!query) { | ||
| const known = queryDefinitions().map((entry) => entry.name).join(", "); | ||
| const known = publicQueryDefinitions().map((entry) => entry.name).join(", "); | ||
| throw new Error(`Unknown query '${name}'. Run 'topogram query list' to inspect available queries. Known queries: ${known}`); | ||
@@ -342,3 +390,3 @@ } | ||
| console.log("Common queries:"); | ||
| for (const query of queryDefinitions()) { | ||
| for (const query of publicQueryDefinitions()) { | ||
| console.log(` ${query.name}`); | ||
@@ -345,0 +393,0 @@ console.log(` ${query.description}`); |
@@ -5,4 +5,6 @@ // @ts-check | ||
| import { buildCheckCommandPayload } from "../../check.js"; | ||
| import { generateWorkspace } from "../../../../generator.js"; | ||
| import { parsePath } from "../../../../parser.js"; | ||
| import { sanitizePublicPayload } from "../../../../public-paths.js"; | ||
| import { | ||
@@ -18,3 +20,7 @@ adoptionPlanPath, | ||
| } from "../workspace.js"; | ||
| import { printContextSliceMarkdown, printJson, printUiDesignCoverageMarkdown, printWorkMapMarkdown } from "./output.js"; | ||
| import { buildContextSavingsReport, formatContextSavingsMarkdown } from "./context-savings.js"; | ||
| import { buildImplementationPrepQuery } from "./implementation-prep.js"; | ||
| import { buildModelingGuideQuery, formatModelingGuideMarkdown } from "./modeling-guide.js"; | ||
| import { buildModelRepairQuery, formatModelRepairMarkdown } from "./model-repair.js"; | ||
| import { printContextSliceHtml, printContextSliceMarkdown, printJson, printUiDesignCoverageMarkdown, printWorkMapMarkdown } from "./output.js"; | ||
@@ -27,5 +33,5 @@ /** | ||
| * @param {AnyRecord} context | ||
| * @returns {number|null} | ||
| * @returns {Promise<number|null>} | ||
| */ | ||
| export function runArtifactQuery(context) { | ||
| export async function runArtifactQuery(context) { | ||
| const queryName = context.commandArgs?.queryName; | ||
@@ -63,4 +69,7 @@ const selectors = selectorOptions(context); | ||
| } | ||
| if (outputFormat === "html") { | ||
| return printContextSliceHtml(result.artifact); | ||
| } | ||
| if (outputFormat && outputFormat !== "json") { | ||
| console.error(`Unsupported query slice output format '${context.outputFormat}'. Use --format markdown or --json.`); | ||
| console.error(`Unsupported query slice output format '${context.outputFormat}'. Use --format html, --format markdown, or --json.`); | ||
| return 2; | ||
@@ -71,2 +80,92 @@ } | ||
| if (queryName === "context-savings") { | ||
| const ast = parsePath(context.inputPath); | ||
| const result = buildSlice(ast, selectors, context.detailId); | ||
| if (!resultOk(result)) return printValidationFailure(result); | ||
| const report = buildContextSavingsReport({ | ||
| ast, | ||
| inputPath: context.inputPath, | ||
| topogramRoot: normalizeTopogramPath(context.inputPath), | ||
| selectors, | ||
| sliceArtifact: result.artifact, | ||
| transcriptPath: context.transcriptPath | ||
| }); | ||
| if (!report.ok) { | ||
| if (report.validation) return printValidationFailure({ validation: report.validation }); | ||
| console.error(report.error || "Failed to build context savings report."); | ||
| return 1; | ||
| } | ||
| const outputFormat = String(context.outputFormat || "").toLowerCase(); | ||
| if (outputFormat === "markdown" || outputFormat === "md") { | ||
| const publicPayload = sanitizePublicPayload(report.report, { projectRoot: process.cwd(), cwd: process.cwd() }); | ||
| process.stdout.write(formatContextSavingsMarkdown(publicPayload)); | ||
| return 0; | ||
| } | ||
| if (outputFormat && outputFormat !== "json") { | ||
| console.error(`Unsupported query context-savings output format '${context.outputFormat}'. Use --format markdown or --json.`); | ||
| return 2; | ||
| } | ||
| return printJson(report.report); | ||
| } | ||
| if (queryName === "implementation-prep") { | ||
| const check = await buildCheckCommandPayload(context.inputPath); | ||
| const prepSelectors = { | ||
| ...selectors, | ||
| modeId: selectors.modeId || "implementation" | ||
| }; | ||
| const result = check.payload.ok | ||
| ? buildSlice(check.ast, prepSelectors, context.detailId || "compact") | ||
| : { ok: false, validation: { errors: check.payload.errors || [], warnings: check.payload.warnings || [] } }; | ||
| const repairReport = check.payload.ok ? null : await buildModelRepairQuery(context.inputPath); | ||
| const report = buildImplementationPrepQuery({ | ||
| selectors: prepSelectors, | ||
| sliceResult: result, | ||
| checkPayload: check.payload, | ||
| projectRoot: check.publicContext.projectRoot, | ||
| topogramRoot: normalizeTopogramPath(context.inputPath), | ||
| detailId: context.detailId || "compact", | ||
| includeFiles: context.includeFiles || [], | ||
| graph: check.resolved?.graph || null, | ||
| repairReport | ||
| }); | ||
| const outputFormat = String(context.outputFormat || "").toLowerCase(); | ||
| if (outputFormat && outputFormat !== "json") { | ||
| console.error(`Unsupported query implementation-prep output format '${context.outputFormat}'. Use --json.`); | ||
| return 2; | ||
| } | ||
| printJson(report); | ||
| return report.ok ? 0 : 1; | ||
| } | ||
| if (queryName === "repair-model") { | ||
| const report = await buildModelRepairQuery(context.inputPath); | ||
| const outputFormat = String(context.outputFormat || "").toLowerCase(); | ||
| if (outputFormat === "markdown" || outputFormat === "md") { | ||
| const publicPayload = sanitizePublicPayload(report, { projectRoot: process.cwd(), cwd: process.cwd() }); | ||
| process.stdout.write(formatModelRepairMarkdown(publicPayload)); | ||
| return 0; | ||
| } | ||
| if (outputFormat && outputFormat !== "json") { | ||
| console.error(`Unsupported query repair-model output format '${context.outputFormat}'. Use --format markdown or --json.`); | ||
| return 2; | ||
| } | ||
| return printJson(report); | ||
| } | ||
| if (queryName === "modeling-guide") { | ||
| const report = await buildModelingGuideQuery(context.inputPath, context.modeId); | ||
| const outputFormat = String(context.outputFormat || "").toLowerCase(); | ||
| if (outputFormat === "markdown" || outputFormat === "md") { | ||
| const publicPayload = sanitizePublicPayload(report, { projectRoot: process.cwd(), cwd: process.cwd() }); | ||
| process.stdout.write(formatModelingGuideMarkdown(publicPayload)); | ||
| return 0; | ||
| } | ||
| if (outputFormat && outputFormat !== "json") { | ||
| console.error(`Unsupported query modeling-guide output format '${context.outputFormat}'. Use --format markdown or --json.`); | ||
| return 2; | ||
| } | ||
| return printJson(report); | ||
| } | ||
| if (queryName === "adoption-plan") { | ||
@@ -73,0 +172,0 @@ const topogramRoot = normalizeTopogramPath(context.inputPath); |
@@ -27,3 +27,3 @@ // @ts-check | ||
| ]) { | ||
| const result = handler(context); | ||
| const result = await handler(context); | ||
| if (result !== null) { | ||
@@ -30,0 +30,0 @@ return result; |
@@ -43,2 +43,18 @@ // @ts-check | ||
| /** | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord|null|undefined} frame | ||
| * @returns {void} | ||
| */ | ||
| function pushFrame(lines, frame) { | ||
| if (!frame || typeof frame !== "object") return; | ||
| lines.push("", "## Start Here"); | ||
| pushField(lines, "Goal", frame.goal); | ||
| pushField(lines, "Current state", frame.current_state); | ||
| pushField(lines, "Done when", frame.done_when); | ||
| pushField(lines, "Risk", frame.risk); | ||
| pushField(lines, "Recommended next action", frame.recommended_next_action); | ||
| pushField(lines, "Non-goals", frame.non_goals); | ||
| } | ||
| /** | ||
| * @param {AnyRecord|null|undefined} item | ||
@@ -116,3 +132,2 @@ * @returns {string} | ||
| pushField(lines, "Mode", guidance.mode); | ||
| pushField(lines, "Read first", guidance.read_first); | ||
| pushField(lines, "Read order", guidance.read_order); | ||
@@ -140,2 +155,63 @@ const nextQueries = stringList(guidance.next_queries); | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord[]|null|undefined} items | ||
| * @returns {void} | ||
| */ | ||
| function pushWorkItems(lines, items) { | ||
| if (!Array.isArray(items) || items.length === 0) return; | ||
| lines.push("", "## Work Items"); | ||
| for (const item of items.slice(0, 20)) { | ||
| lines.push(`- ${itemLabel(item) || text(item.id)} (${text(item.tier || "reference")}, ${text(item.role || item.kind || "item")})`); | ||
| if (item.why_included) lines.push(` - Why: ${text(item.why_included)}`); | ||
| if (item.suggested_action) lines.push(` - Action: ${text(item.suggested_action)}`); | ||
| if (item.source_ref?.file) lines.push(` - Source: \`${text(item.source_ref.file)}${item.source_ref.line ? `:${text(item.source_ref.line)}` : ""}\``); | ||
| } | ||
| if (items.length > 20) lines.push(`- ... ${items.length - 20} more`); | ||
| } | ||
| /** | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord[]|null|undefined} contracts | ||
| * @returns {void} | ||
| */ | ||
| function pushImplementationContracts(lines, contracts) { | ||
| if (!Array.isArray(contracts) || contracts.length === 0) return; | ||
| lines.push("", "## Implementation Contracts"); | ||
| for (const contract of contracts.slice(0, 24)) { | ||
| lines.push(`- \`${text(contract.id)}\`: \`${text(contract.method)} ${text(contract.path)}\` -> \`${text(contract.success_status)}\``); | ||
| if (contract.capability_id) lines.push(` - Capability: \`${text(contract.capability_id)}\``); | ||
| if (contract.request) lines.push(` - Request: \`${text(contract.request)}\``); | ||
| const response = contract.response || {}; | ||
| if (response.result || response.container || response.entity_id) { | ||
| lines.push(` - Response: \`${text(response.result)}\` in \`${text(response.container)}\`${response.entity_id ? ` from \`${text(response.entity_id)}\`` : ""}${response.inferred ? " (partly inferred)" : ""}`); | ||
| } | ||
| const seedExamples = Array.isArray(contract.seed_examples) ? contract.seed_examples : []; | ||
| if (seedExamples.length > 0) { | ||
| const recordIds = seedExamples.flatMap(/** @param {AnyRecord} seed */ (seed) => | ||
| (seed.records || []).map(/** @param {AnyRecord} record */ (record) => record.id) | ||
| ).filter(Boolean).slice(0, 5); | ||
| lines.push(` - Seed examples: ${recordIds.map((id) => `\`${text(id)}\``).join(", ")}`); | ||
| } | ||
| const verifications = stringList(contract.verification_ids); | ||
| if (verifications.length > 0) lines.push(` - Verification: ${verifications.map((id) => `\`${id}\``).join(", ")}`); | ||
| } | ||
| if (contracts.length > 24) lines.push(`- ... ${contracts.length - 24} more`); | ||
| } | ||
| /** | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord[]|null|undefined} relationships | ||
| * @returns {void} | ||
| */ | ||
| function pushRelationships(lines, relationships) { | ||
| if (!Array.isArray(relationships) || relationships.length === 0) return; | ||
| lines.push("", "## Relationships"); | ||
| for (const relation of relationships.slice(0, 30)) { | ||
| lines.push(`- \`${text(relation.relation)}\` -> \`${text(relation.to?.kind || "item")}:${text(relation.to?.id)}\` (${text(relation.tier)})`); | ||
| if (relation.why) lines.push(` - ${text(relation.why)}`); | ||
| } | ||
| if (relationships.length > 30) lines.push(`- ... ${relationships.length - 30} more`); | ||
| } | ||
| /** | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord|null|undefined} manifest | ||
@@ -259,2 +335,35 @@ * @returns {void} | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord|null|undefined} proofPlan | ||
| * @returns {void} | ||
| */ | ||
| function pushProofPlan(lines, proofPlan) { | ||
| if (!proofPlan || typeof proofPlan !== "object") return; | ||
| lines.push("", "## Proof Plan"); | ||
| pushField(lines, "Required commands", proofPlan.required?.commands); | ||
| pushField(lines, "Required verifications", proofPlan.required?.verification_ids); | ||
| pushField(lines, "Recommended commands", proofPlan.recommended?.commands); | ||
| pushField(lines, "Expensive verifications", proofPlan.expensive?.verification_ids); | ||
| pushField(lines, "Already satisfied", proofPlan.already_satisfied?.verification_ids); | ||
| } | ||
| /** | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord|null|undefined} budget | ||
| * @returns {void} | ||
| */ | ||
| function pushAttentionBudget(lines, budget) { | ||
| if (!budget || typeof budget !== "object") return; | ||
| lines.push("", "## Attention Budget"); | ||
| if (budget.total) { | ||
| lines.push(`- Total estimate: \`${text(budget.total.estimated_tokens)}\` tokens / \`${text(budget.total.bytes)}\` bytes`); | ||
| } | ||
| const sections = Array.isArray(budget.sections) ? budget.sections : []; | ||
| for (const section of sections.slice(0, 12)) { | ||
| lines.push(`- ${text(section.title || section.id)} (${text(section.tier)}): \`${text(section.estimated_tokens)}\` tokens`); | ||
| } | ||
| if (sections.length > 12) lines.push(`- ... ${sections.length - 12} more`); | ||
| } | ||
| /** | ||
| * @param {string[]} lines | ||
| * @param {AnyRecord|null|undefined} writeScope | ||
@@ -365,3 +474,3 @@ * @returns {void} | ||
| if (widgets.length === 0) return; | ||
| lines.push("### Widget Bindings"); | ||
| lines.push("### Screen Renders"); | ||
| for (const widget of widgets.slice(0, 8)) { | ||
@@ -460,2 +569,3 @@ if (!widget || typeof widget !== "object") continue; | ||
| pushFrame(lines, slice.frame); | ||
| if (slice.review_boundary) { | ||
@@ -471,2 +581,5 @@ lines.push("", "## Review Boundary"); | ||
| pushAgentGuidance(lines, slice.agent_guidance); | ||
| pushWorkItems(lines, slice.work_items); | ||
| pushImplementationContracts(lines, slice.implementation_contracts); | ||
| pushRelationships(lines, slice.relationships); | ||
| pushUiAgentPacket(lines, slice.ui_agent_packet); | ||
@@ -477,3 +590,5 @@ pushStandingRules(lines, slice.standing_rules); | ||
| pushRelated(lines, "Related", slice.related); | ||
| pushProofPlan(lines, slice.proof_plan); | ||
| pushVerificationTargets(lines, slice.verification_targets); | ||
| pushAttentionBudget(lines, slice.attention_budget); | ||
| pushWriteScope(lines, slice.write_scope); | ||
@@ -480,0 +595,0 @@ |
| // @ts-check | ||
| import { sanitizePublicPayload, stablePublicStringify } from "../../../../public-paths.js"; | ||
| import { formatContextSliceHtml } from "../../../../generator/context/slice/html.js"; | ||
| import { formatUiDesignCoverageMarkdown } from "../../../../generator/surfaces/web/ui-design-coverage.js"; | ||
@@ -31,2 +32,12 @@ import { formatWorkMapReportMarkdown } from "../../../../generator/surfaces/web/work-map-report-markdown.js"; | ||
| */ | ||
| export function printContextSliceHtml(payload) { | ||
| const publicPayload = sanitizePublicPayload(payload, { projectRoot: process.cwd(), cwd: process.cwd() }); | ||
| process.stdout.write(formatContextSliceHtml(publicPayload)); | ||
| return 0; | ||
| } | ||
| /** | ||
| * @param {Record<string, any>} payload | ||
| * @returns {0} | ||
| */ | ||
| export function printUiDesignCoverageMarkdown(payload) { | ||
@@ -33,0 +44,0 @@ const publicPayload = sanitizePublicPayload(payload, { projectRoot: process.cwd(), cwd: process.cwd() }); |
@@ -82,2 +82,3 @@ // @ts-check | ||
| options.domainId || | ||
| options.featureId || | ||
| options.pitchId || | ||
@@ -228,2 +229,3 @@ options.requirementId || | ||
| options.domainId || | ||
| options.featureId || | ||
| options.pitchId || | ||
@@ -266,2 +268,3 @@ options.requirementId || | ||
| domainId: options.domainId, | ||
| featureId: options.featureId, | ||
| pitchId: options.pitchId, | ||
@@ -268,0 +271,0 @@ requirementId: options.requirementId, |
@@ -168,2 +168,7 @@ // @ts-check | ||
| const appVersion = flagValue(args, "--app-version"); | ||
| const feature = flagValue(args, "--feature"); | ||
| const phase = flagValue(args, "--phase"); | ||
| const scope = flagValue(args, "--scope"); | ||
| const taskIntent = flagValue(args, "--intent"); | ||
| const taskSuccess = flagValue(args, "--success"); | ||
| const sinceTag = flagValue(args, "--since-tag"); | ||
@@ -284,2 +289,30 @@ const base = flagValue(args, "--base"); | ||
| if (commandArgs.sdlcCommand === "verify:record-batch") { | ||
| const { | ||
| loadVerificationReceiptBatchFile, | ||
| recordVerificationRunBatch | ||
| } = await import("../../sdlc/verification-runs.js"); | ||
| const fromFile = flagValue(args, "--from-file"); | ||
| const loaded = loadVerificationReceiptBatchFile(fromFile || ""); | ||
| const result = loaded.ok | ||
| ? recordVerificationRunBatch(sdlcRoot, loaded.receipts, { | ||
| taskId: flagValue(args, "--task"), | ||
| actor, | ||
| status, | ||
| commit, | ||
| write: args.includes("--write") && !dryRun, | ||
| sourceFile: loaded.path || fromFile || null | ||
| }) | ||
| : { | ||
| ok: false, | ||
| type: "verification_run_batch_record", | ||
| version: 1, | ||
| dryRun: !args.includes("--write") || dryRun, | ||
| error: loaded.error, | ||
| source_file: loaded.path || fromFile || null | ||
| }; | ||
| console.log(sdlcJson(result, sdlcRoot)); | ||
| return result.ok ? 0 : 1; | ||
| } | ||
| if (commandArgs.sdlcCommand === "link") { | ||
@@ -425,3 +458,35 @@ const { linkSdlcRecord } = await import("../../sdlc/link.js"); | ||
| const { scaffoldNew } = await import("../../sdlc/scaffold.js"); | ||
| const result = scaffoldNew(sdlcRoot, commandArgs.sdlcNewKind, commandArgs.sdlcNewSlug); | ||
| const result = scaffoldNew(sdlcRoot, commandArgs.sdlcNewKind, commandArgs.sdlcNewSlug, { | ||
| feature, | ||
| phase, | ||
| scope, | ||
| intent: taskIntent, | ||
| success: taskSuccess, | ||
| write: args.includes("--write") | ||
| }); | ||
| if (result.ok && commandArgs.sdlcNewKind === "task" && args.includes("--start")) { | ||
| if (!args.includes("--write")) { | ||
| const payload = { | ||
| ...result, | ||
| ok: false, | ||
| error: "sdlc new task --start requires --write so the task can be created before the command-owned start transition." | ||
| }; | ||
| console.log(sdlcJson(payload, sdlcRoot)); | ||
| return 1; | ||
| } | ||
| const { startTask } = await import("../../sdlc/start.js"); | ||
| const taskId = `task_${commandArgs.sdlcNewSlug}`; | ||
| const startResult = startTask(sdlcRoot, taskId, { | ||
| actor, | ||
| write: true, | ||
| note: note || "task created and started" | ||
| }); | ||
| const payload = { | ||
| ...result, | ||
| task_id: taskId, | ||
| start: startResult | ||
| }; | ||
| console.log(sdlcJson(payload, sdlcRoot)); | ||
| return result.ok && startResult.ok ? 0 : 1; | ||
| } | ||
| console.log(sdlcJson(result, sdlcRoot)); | ||
@@ -428,0 +493,0 @@ return result.ok ? 0 : 1; |
+102
-1
@@ -10,2 +10,3 @@ // @ts-check | ||
| import { runEmitCommand } from "./commands/emit.js"; | ||
| import { runFeatureCommand } from "./commands/feature.js"; | ||
| import { runGenerateAppCommand } from "./commands/generate.js"; | ||
@@ -17,2 +18,3 @@ import { runExtractorCommand } from "./commands/extractor.js"; | ||
| import { runInitProjectCommand } from "./commands/init.js"; | ||
| import { runOnboardCommand } from "./commands/onboard.js"; | ||
| import { runPackageCommand } from "./commands/package.js"; | ||
@@ -32,2 +34,3 @@ import { runParseCommand, runResolveCommand } from "./commands/inspect.js"; | ||
| import { runTemplateCommand } from "./commands/template-runner.js"; | ||
| import { runTraceCommand } from "./commands/trace.js"; | ||
| import { runTrustCommand } from "./commands/trust.js"; | ||
@@ -38,2 +41,3 @@ import { | ||
| } from "./commands/widget.js"; | ||
| import { runWorkCommand } from "./commands/work.js"; | ||
| import { | ||
@@ -49,2 +53,3 @@ runLegacyWorkflowCommand, | ||
| import { printQueryHelp, printUsage } from "./help-dispatch.js"; | ||
| import { optionValue } from "./options.js"; | ||
| import { stableStringify } from "../format.js"; | ||
@@ -89,2 +94,4 @@ import { sanitizePublicPayload, stablePublicStringify } from "../public-paths.js"; | ||
| shouldResolve, | ||
| shouldStrict, | ||
| shouldGenerate, | ||
| generateTarget, | ||
@@ -109,5 +116,8 @@ workflowName, | ||
| laneId, | ||
| transcriptPath, | ||
| fromSnapshotPath, | ||
| fromTopogramPath, | ||
| checkPath, | ||
| seedFile, | ||
| shouldRunVerify, | ||
| shouldWrite, | ||
@@ -131,2 +141,3 @@ refreshAdopted, | ||
| domainId, | ||
| featureId, | ||
| seamId, | ||
@@ -172,3 +183,3 @@ taskId, | ||
| if ((shouldCheck || shouldWidgetCheck || shouldWidgetBehavior || shouldAgentBrief || shouldValidate || commandArgs?.generatorPolicyCommand || commandArgs?.trustCommand || commandArgs?.queryName || commandArgs?.workflowPresetCommand || generateTarget) && inputPath) { | ||
| if ((shouldCheck || shouldWidgetCheck || shouldWidgetBehavior || shouldAgentBrief || shouldValidate || commandArgs?.generatorPolicyCommand || commandArgs?.trustCommand || commandArgs?.queryName || commandArgs?.workflowPresetCommand || commandArgs?.workCommand || generateTarget) && inputPath) { | ||
| inputPath = normalizeTopogramPath(inputPath); | ||
@@ -322,2 +333,77 @@ } | ||
| if (commandArgs?.onboardCommand) { | ||
| return runOnboardCommand({ | ||
| inputPath: inputPath || ".", | ||
| json: emitJson, | ||
| write: shouldWrite, | ||
| outDir: effectiveOutDir, | ||
| taskId, | ||
| bugId, | ||
| generate: shouldGenerate, | ||
| runVerify: shouldRunVerify, | ||
| strict: shouldStrict, | ||
| cwd: process.cwd() | ||
| }); | ||
| } | ||
| if (commandArgs?.featureCommand) { | ||
| return runFeatureCommand({ | ||
| commandArgs, | ||
| inputPath: inputPath || ".", | ||
| intent: optionValue(args, "--intent"), | ||
| write: shouldWrite, | ||
| json: emitJson, | ||
| cwd: process.cwd() | ||
| }); | ||
| } | ||
| if (commandArgs?.workCommand) { | ||
| return runWorkCommand({ | ||
| commandArgs, | ||
| inputPath: effectiveInputPath, | ||
| selectors: { | ||
| shapeId, | ||
| capabilityId, | ||
| workflowId, | ||
| projectionId, | ||
| screenId, | ||
| layoutId, | ||
| regionId, | ||
| designRealizationSetId, | ||
| widgetId: componentId, | ||
| componentId, | ||
| entityId, | ||
| journeyId, | ||
| surfaceId, | ||
| domainId, | ||
| featureId, | ||
| pitchId, | ||
| requirementId, | ||
| acceptanceId, | ||
| taskId, | ||
| verificationId, | ||
| planId, | ||
| bugId, | ||
| documentId, | ||
| seamId, | ||
| modeId | ||
| }, | ||
| modeId: modeId || "implementation", | ||
| detailId: detailId || "compact", | ||
| includeFiles: cliOptions.includeFiles || [], | ||
| implementerId: cliOptions.implementerId || null, | ||
| appState: cliOptions.appState || null, | ||
| json: emitJson | ||
| }); | ||
| } | ||
| if (commandArgs?.traceCommand) { | ||
| return runTraceCommand({ | ||
| commandArgs, | ||
| args, | ||
| json: emitJson, | ||
| outputFormat | ||
| }); | ||
| } | ||
| if (commandArgs?.templateCommand) { | ||
@@ -359,2 +445,3 @@ return runTemplateCommand({ | ||
| domainId, | ||
| featureId, | ||
| pitchId, | ||
@@ -376,2 +463,4 @@ requirementId, | ||
| laneId, | ||
| includeFiles: cliOptions.includeFiles || [], | ||
| transcriptPath, | ||
| fromTopogramPath, | ||
@@ -421,2 +510,3 @@ shouldWrite, | ||
| checkPath, | ||
| seedFile, | ||
| selectors: { | ||
@@ -437,2 +527,3 @@ shapeId, | ||
| domainId, | ||
| featureId, | ||
| taskId, | ||
@@ -443,2 +534,3 @@ pitchId, | ||
| bugId, | ||
| planId, | ||
| documentId, | ||
@@ -464,3 +556,12 @@ kind: sdlcKind, | ||
| journeyId, | ||
| surfaceId, | ||
| domainId, | ||
| featureId, | ||
| pitchId, | ||
| requirementId, | ||
| acceptanceId, | ||
| taskId, | ||
| planId, | ||
| bugId, | ||
| documentId, | ||
| modeId, | ||
@@ -467,0 +568,0 @@ detailId, |
@@ -26,2 +26,5 @@ // @ts-check | ||
| import { | ||
| printOnboardHelp | ||
| } from "./commands/onboard.js"; | ||
| import { | ||
| printPackageHelp | ||
@@ -48,2 +51,5 @@ } from "./commands/package.js"; | ||
| import { | ||
| printTraceHelp | ||
| } from "./commands/trace.js"; | ||
| import { | ||
| printTrustHelp | ||
@@ -53,2 +59,3 @@ } from "./commands/trust.js"; | ||
| printEmitHelp, | ||
| printFeatureHelp, | ||
| printGenerateHelp, | ||
@@ -58,2 +65,3 @@ printInitHelp, | ||
| printUsage, | ||
| printWorkHelp, | ||
| printWidgetHelp | ||
@@ -75,2 +83,18 @@ } from "./help.js"; | ||
| } | ||
| if (command === "onboard") { | ||
| printOnboardHelp(); | ||
| return true; | ||
| } | ||
| if (command === "work") { | ||
| printWorkHelp(); | ||
| return true; | ||
| } | ||
| if (command === "feature") { | ||
| printFeatureHelp(); | ||
| return true; | ||
| } | ||
| if (command === "trace") { | ||
| printTraceHelp(); | ||
| return true; | ||
| } | ||
| if (command === "generate") { | ||
@@ -218,2 +242,10 @@ printGenerateHelp(); | ||
| } | ||
| if (args[0] === "work") { | ||
| printWorkHelp(); | ||
| return args[1] ? 1 : 0; | ||
| } | ||
| if (args[0] === "feature") { | ||
| printFeatureHelp(); | ||
| return args[1] ? 1 : 0; | ||
| } | ||
| return null; | ||
@@ -220,0 +252,0 @@ } |
+61
-55
@@ -16,2 +16,6 @@ // @ts-check | ||
| console.log("Usage: topogram runtime add web api database [path] [--dry-run] [--json]"); | ||
| console.log("Usage: topogram onboard [path] [--write --out-dir <path>] [--generate] [--run-verify] [--json]"); | ||
| console.log("Usage: topogram feature new <slug> [path] [--intent <text>] [--write] [--json]"); | ||
| console.log("Usage: topogram work <next|advance> [path] --task <task-id> [--mode implementation] [--implementer <id>] [--app-state unknown|minimal_placeholder|maintained] [--json]"); | ||
| console.log("Usage: topogram trace analyze <run-dir> [--audit-bundle <bundle-dir>] [--json]"); | ||
| console.log("Usage: topogram check [path] [--json]"); | ||
@@ -28,2 +32,3 @@ console.log(" or: topogram widget check [path] [--surface <id>] [--widget <id>] [--json]"); | ||
| console.log(" or: topogram sdlc complete <task-id> [path] --verification <verification-id> [--dry-run|--write]"); | ||
| console.log(" or: topogram sdlc verify record-batch [path] --from-file <receipts.json> [--task <task-id>] [--actor <actor>] [--write] [--json]"); | ||
| console.log(" or: topogram sdlc plan create <task-id> <slug> [path] [--write]"); | ||
@@ -94,2 +99,8 @@ console.log(" or: topogram sdlc plan explain <plan-id> [path] [--json]"); | ||
| console.log(" topogram runtime add web api database"); | ||
| console.log(" topogram onboard"); | ||
| console.log(" topogram onboard --write --out-dir ./artifacts"); | ||
| console.log(" topogram feature new waitlist_reminders . --intent \"Handle waitlist reminders\" --write --json"); | ||
| console.log(" topogram work next ./topo --task <task-id> --mode implementation --json"); | ||
| console.log(" topogram work advance ./topo --task <task-id> --mode implementation --json"); | ||
| console.log(" topogram trace analyze ./.tmp/slice-benefit-pilot/pilot-37 --json"); | ||
| console.log(" topogram init ."); | ||
@@ -113,2 +124,4 @@ console.log(" topogram copy --list"); | ||
| console.log(" topogram query show widget-behavior"); | ||
| console.log(" topogram query slice ./topo --task <task-id> --format html"); | ||
| console.log(" topogram emit audit-bundle ./topo --task <task-id> --profile standard --write --out-dir ./artifacts"); | ||
| console.log(" topogram query widget-behavior ./topo --surface proj_web --json"); | ||
@@ -146,3 +159,3 @@ console.log(" topogram query work-map ./topo --surface proj_web --format markdown"); | ||
| console.log(" npx topogram copy hello-web ./my-app"); | ||
| console.log(" cd ./my-app && npm install && npm run check && npm run generate"); | ||
| console.log(" cd ./my-app && npm install && npm run onboard && npm run generate"); | ||
| console.log(" npm --prefix app run compile"); | ||
@@ -185,55 +198,7 @@ console.log(""); | ||
| console.log("Run `topogram help <command>` for command-specific help."); | ||
| console.log("Run `topogram help all` for legacy and agent-facing commands."); | ||
| if (!all) { | ||
| return; | ||
| console.log("Run `topogram help all` for the full public command reference."); | ||
| if (all) { | ||
| console.log(""); | ||
| console.log("Help all intentionally shows public CLI forms only; internal compatibility forms are hidden."); | ||
| } | ||
| console.log(""); | ||
| console.log("Internal commands:"); | ||
| console.log(" or: topogram template show <id> [--json] [--catalog <path-or-source>]"); | ||
| console.log(" or: topogram validate <path>"); | ||
| console.log(" or: node ./src/cli.js query work-packet <path> --mode extract-adopt --lane <id>"); | ||
| console.log(" or: node ./src/cli.js <path> [--json] [--validate] [--resolve] [--workflow <name>] [--mode <id>] [--from <track[,track]>] [--adopt <selector>] [--refresh-adopted] [--shape <id>] [--capability <id>] [--widget <id>] [--surface <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--task <id>] [--profile <id>] [--from-snapshot <path>] [--from-topogram <path>] [--write] [--out-dir <path>]"); | ||
| console.log(" or: node ./src/cli.js emit <target> [path] [--json] [--write] [--out-dir <path>]"); | ||
| console.log(" or: node ./src/cli.js generate journeys <path> [--write]"); | ||
| console.log(" or: node ./src/cli.js report gaps <path> [--write]"); | ||
| console.log(" or: node ./src/cli.js query task-mode <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query adoption-plan <path>"); | ||
| console.log(" or: node ./src/cli.js query maintained-boundary <path>"); | ||
| console.log(" or: node ./src/cli.js query maintained-conformance <path> [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query maintained-drift <path> --from-topogram <path>"); | ||
| console.log(" or: node ./src/cli.js query seam-check <path> [--seam <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query diff <path> --from-topogram <path>"); | ||
| console.log(" or: node ./src/cli.js query slice <path> [--capability <id>] [--workflow <id>] [--surface <id>] [--screen <id>] [--layout <id>] [--region <id>] [--component-map <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--domain <id>] [--task <id>] [--plan <id>] [--bug <id>] [--detail compact|standard|full] [--format markdown]"); | ||
| console.log(" or: node ./src/cli.js query work-map <path> [--surface <id>] [--screen <id>] [--layout <id>] [--region <id>] [--component-map <id>] [--widget <id>] [--format markdown]"); | ||
| console.log(" or: node ./src/cli.js query domain-list <path>"); | ||
| console.log(" or: node ./src/cli.js query domain-coverage <path> --domain <id>"); | ||
| console.log(" or: node ./src/cli.js query review-boundary <path> [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>]"); | ||
| console.log(" or: node ./src/cli.js query write-scope <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query verification-targets <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query widget-behavior <path> [--surface <id>] [--widget <id>] [--json]"); | ||
| console.log(" or: node ./src/cli.js query change-plan <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query extract-plan <path>"); | ||
| console.log(" or: node ./src/cli.js query risk-summary <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query canonical-writes <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query proceed-decision <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query review-packet <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query next-action <path> [--mode <id>] [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query single-agent-plan <path> --mode <id> [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--task <id>] [--plan <id>] [--bug <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query multi-agent-plan <path> --mode extract-adopt"); | ||
| console.log(" or: node ./src/cli.js query resolved-workflow-context <path> --mode <id> [--capability <id>] [--workflow <id>] [--surface <id>] [--widget <id>] [--entity <id>] [--journey <id>] [--surface <id>] [--provider <id>] [--preset <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query workflow-preset-activation <path> --mode <id> [--provider <id>] [--preset <id>] [--from-topogram <path>]"); | ||
| console.log(" or: node ./src/cli.js query workflow-preset-diff <path> --provider <id> [--preset <id>]"); | ||
| console.log(" or: node ./src/cli.js query workflow-preset-customization <path> --provider <id> --preset <id>"); | ||
| console.log(" or: node ./src/cli.js workflow-preset customize <path> --provider <id> --preset <id> [--out <path>] [--write]"); | ||
| console.log(" or: node ./src/cli.js query lane-status <path> --mode extract-adopt"); | ||
| console.log(" or: node ./src/cli.js query handoff-status <path> --mode extract-adopt"); | ||
| console.log(" or: node ./src/cli.js query auth-hints <path>"); | ||
| console.log(" or: node ./src/cli.js query auth-review-packet <path> --bundle <slug>"); | ||
| console.log(" or: node ./src/cli.js reconcile <path> [--write]"); | ||
| console.log(" or: node ./src/cli.js reconcile adopt <selector> <path> [--refresh-adopted] [--write]"); | ||
| console.log(" or: node ./src/cli.js adoption status <path> [--write]"); | ||
| console.log("Targets: json-schema, docs, docs-index, verification-plan, verification-checklist, shape-transform-graph, shape-transform-debug, api-contract-graph, api-contract-debug, ui-contract-graph, ui-contract-debug, ui-widget-contract, widget-conformance-report, widget-behavior-report, ui-surface-contract, ui-surface-debug, ui-realization-report, ui-design-coverage, work-map-report, sveltekit-app, swiftui-app, db-contract-graph, db-contract-debug, db-schema-snapshot, db-migration-plan, db-lifecycle-plan, db-lifecycle-bundle, environment-plan, environment-bundle, deployment-plan, deployment-bundle, runtime-smoke-plan, runtime-smoke-bundle, runtime-check-plan, runtime-check-bundle, runtime-e2e-plan, runtime-e2e-bundle, compile-check-plan, compile-check-bundle, app-bundle-plan, app-bundle, native-parity-plan, native-parity-bundle, sql-migration, sql-schema, prisma-schema, drizzle-schema, persistence-scaffold, server-contract, hono-server, openapi, context-digest, context-diff, context-slice, context-bundle, context-report, context-task-mode"); | ||
| console.log("Workflows: scan-docs, reconcile, adoption-status, generate-docs, generate-journeys, refresh-docs, report-gaps"); | ||
| console.log("Extract tracks: db, api, ui, cli, workflows, verification"); | ||
| console.log("Reconcile adopt selectors: from-plan, actors, roles, enums, shapes, entities, capabilities, widgets, docs, journeys, workflows, ui, bundle:<slug>, surface-review:<id>, ui-review:<id>, workflow-review:<id>, bundle-review:<slug>"); | ||
| } | ||
@@ -254,3 +219,3 @@ | ||
| console.log(" npx topogram copy hello-web ./my-app"); | ||
| console.log(" cd ./my-app && npm install && npm run check && npm run generate"); | ||
| console.log(" cd ./my-app && npm install && npm run onboard && npm run generate"); | ||
| console.log(" npm --prefix app run compile"); | ||
@@ -291,2 +256,33 @@ console.log(""); | ||
| */ | ||
| export function printWorkHelp() { | ||
| console.log("Usage: topogram work <next|advance> [path] --task <task-id> [--mode implementation] [--json]"); | ||
| console.log(""); | ||
| console.log("Returns the canonical next-action packet for agents. The packet owns workflow state, allowed and blocked actions, exact edit targets, current endpoint contracts, and a checkpoint summary."); | ||
| console.log("`work advance` is a read-only preview of the preferred executable action for runners that batch packet actions safely."); | ||
| console.log(""); | ||
| console.log("Defaults: path is ./topo. Use --mode maintained-app-edit when a hand-maintained app should receive patch guidance instead of scaffold blocking. Use --implementer and --app-state only when an automation runner has explicit stack and ownership context."); | ||
| console.log(""); | ||
| console.log("Examples:"); | ||
| console.log(" topogram work next ./topo --task task_example --mode implementation --json"); | ||
| console.log(" topogram work advance ./topo --task task_example --mode implementation --json"); | ||
| console.log(" topogram work next ./topo --task task_example --mode maintained-app-edit --implementer node-http-maintained --app-state maintained --json"); | ||
| } | ||
| /** | ||
| * @returns {void} | ||
| */ | ||
| export function printFeatureHelp() { | ||
| console.log("Usage: topogram feature new <slug> [path] [--intent <text>] [--write] [--json]"); | ||
| console.log(""); | ||
| console.log("Creates or previews a semantic feature scope record. New product work should start with a feature, then create a feature-linked SDLC task and run work next."); | ||
| console.log(""); | ||
| console.log("Examples:"); | ||
| console.log(" topogram feature new waitlist_reminders . --intent \"Handle waitlist reminders\" --json"); | ||
| console.log(" topogram feature new waitlist_reminders . --intent \"Handle waitlist reminders\" --write --json"); | ||
| console.log(" topogram sdlc new task waitlist_reminders . --feature feature_waitlist_reminders --phase implementation --scope current_feature --start --actor actor_coding_agent --write --json"); | ||
| } | ||
| /** | ||
| * @returns {void} | ||
| */ | ||
| export function printGenerateHelp() { | ||
@@ -326,2 +322,4 @@ console.log("Usage: topogram generate [path] [--out <path>]"); | ||
| console.log(" context-diff"); | ||
| console.log(" audit-bundle"); | ||
| console.log(" node-http-api-scaffold"); | ||
| console.log(" glossary"); | ||
@@ -337,2 +335,6 @@ console.log(" verification-targets"); | ||
| console.log(" --journey <id>"); | ||
| console.log(" --task <id>"); | ||
| console.log(" --bug <id>"); | ||
| console.log(" --profile standard|adoption|bug"); | ||
| console.log(" --seed-file <path>"); | ||
| console.log(""); | ||
@@ -344,2 +346,6 @@ console.log("Examples:"); | ||
| console.log(" topogram emit ui-realization-report ./topo --surface proj_web --json"); | ||
| console.log(" topogram emit context-slice ./topo --task <task-id> --format html --write --out-dir ./artifacts"); | ||
| console.log(" topogram emit audit-bundle ./topo --task <task-id> --profile standard --write --out-dir ./artifacts"); | ||
| console.log(" topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --json"); | ||
| console.log(" topogram emit node-http-api-scaffold ./topo --seed-file seed-fixture.json --write --out-dir ./generated-scaffold"); | ||
| console.log(" topogram emit work-map-report ./topo --surface proj_web --format markdown"); | ||
@@ -360,3 +366,3 @@ console.log(" topogram emit db-schema-snapshot ./topo --surface proj_db_postgres --json"); | ||
| console.log(""); | ||
| console.log("Checks surface widget_bindings usage against reusable widget contracts and behavior realizations."); | ||
| console.log("Checks screen render usage against reusable widget contracts and behavior realizations."); | ||
| console.log(""); | ||
@@ -363,0 +369,0 @@ console.log("Defaults: path is ./topo."); |
@@ -52,3 +52,3 @@ // @ts-check | ||
| } | ||
| const removedGenerateIndex = args.indexOf("--generate"); | ||
| const removedGenerateIndex = args[0] === "onboard" ? -1 : args.indexOf("--generate"); | ||
| if (removedGenerateIndex >= 0) { | ||
@@ -55,0 +55,0 @@ const target = args[removedGenerateIndex + 1]; |
@@ -67,2 +67,5 @@ // @ts-check | ||
| shouldResolve: args.includes("--resolve"), | ||
| shouldStrict: args.includes("--strict"), | ||
| shouldGenerate: args.includes("--generate"), | ||
| shouldRunVerify: args.includes("--run-verify"), | ||
| generateTarget, | ||
@@ -78,2 +81,4 @@ workflowName: commandArgs?.workflowName || (!generateTarget && !commandArgs?.queryName && workflowFlagValue ? workflowFlagValue : null), | ||
| detailId: optionValue(args, "--detail"), | ||
| implementerId: optionValue(args, "--implementer"), | ||
| appState: optionValue(args, "--app-state"), | ||
| profileId: optionValue(args, "--profile"), | ||
@@ -88,2 +93,5 @@ providerId: optionValue(args, "--provider"), | ||
| laneId: optionValue(args, "--lane"), | ||
| includeFiles: optionValues(args, "--include-file"), | ||
| seedFile: optionValue(args, "--seed-file"), | ||
| transcriptPath: resolvedPathOption(args, "--transcript"), | ||
| fromSnapshotPath: resolvedPathOption(args, "--from-snapshot"), | ||
@@ -111,2 +119,3 @@ fromTopogramPath: resolvedPathOption(args, "--from-topogram"), | ||
| domainId: optionValue(args, "--domain"), | ||
| featureId: optionValue(args, "--feature"), | ||
| seamId: optionValue(args, "--seam"), | ||
@@ -113,0 +122,0 @@ taskId: optionValue(args, "--task"), |
@@ -310,2 +310,3 @@ import { fieldSignature, symbolList } from "../shared.js"; | ||
| type: "api_endpoint", | ||
| id: apiMetadata.endpointId || null, | ||
| operationId: capability.id, | ||
@@ -317,2 +318,5 @@ method: apiMetadata.method, | ||
| requestPlacement: apiMetadata.request, | ||
| responseResult: apiMetadata.responseResult || null, | ||
| responseEntity: apiMetadata.responseEntity || null, | ||
| responseContainer: apiMetadata.responseContainer || null, | ||
| surface: apiMetadata.surface, | ||
@@ -319,0 +323,0 @@ preconditions: apiMetadata.preconditions || [], |
@@ -100,2 +100,3 @@ /** | ||
| }, | ||
| endpointId: httpEntry.endpoint?.id || null, | ||
| method: httpEntry.method || methodFromCapability(capability), | ||
@@ -106,2 +107,5 @@ path: httpEntry.path || pathFromCapability(capability), | ||
| request: httpEntry.request || (capability.input.length > 0 ? "body" : "none"), | ||
| responseResult: httpEntry.responseResult || null, | ||
| responseEntity: httpEntry.responseEntity || null, | ||
| responseContainer: httpEntry.responseContainer || null, | ||
| errorMappings: (projection.httpErrors || []) | ||
@@ -203,4 +207,36 @@ .filter(/** @param {any} entry */ (entry) => entry.capability?.id === capability.id) | ||
| const endpoint = (graph.byKind.endpoint || []).find( | ||
| /** @param {any} entry */ | ||
| (entry) => entry.capability?.id === capability.id | ||
| ); | ||
| if (endpoint) { | ||
| return { | ||
| surface: null, | ||
| endpointId: endpoint.id || null, | ||
| method: endpoint.method || methodFromCapability(capability), | ||
| path: endpoint.path || pathFromCapability(capability), | ||
| success: endpoint.success || (capability.creates.length > 0 ? 201 : 200), | ||
| auth: endpoint.auth || "none", | ||
| request: endpoint.request || (capability.input.length > 0 ? "body" : "none"), | ||
| responseResult: endpoint.responseResult || null, | ||
| responseEntity: endpoint.responseEntity?.id || null, | ||
| responseContainer: endpoint.responseContainer || null, | ||
| errorMappings: [], | ||
| fieldBindings: [], | ||
| preconditions: [], | ||
| idempotency: [], | ||
| cache: [], | ||
| delete: [], | ||
| async: [], | ||
| status: [], | ||
| download: [], | ||
| authz: [], | ||
| callbacks: [], | ||
| response: normalizeResponseMetadata(null) | ||
| }; | ||
| } | ||
| return { | ||
| surface: null, | ||
| endpointId: null, | ||
| method: methodFromCapability(capability), | ||
@@ -211,2 +247,5 @@ path: pathFromCapability(capability), | ||
| request: capability.input.length > 0 ? "body" : "none", | ||
| responseResult: null, | ||
| responseEntity: null, | ||
| responseContainer: null, | ||
| errorMappings: [], | ||
@@ -213,0 +252,0 @@ fieldBindings: [], |
@@ -9,2 +9,3 @@ import { generateContextBundle } from "./bundle.js"; | ||
| import { generateAllDomainPages, generateDomainPage } from "./domain-page.js"; | ||
| import { generateAuditBundle } from "./audit-bundle.js"; | ||
@@ -30,2 +31,5 @@ export function generateContextTarget(target, graph, options = {}) { | ||
| } | ||
| if (target === "audit-bundle") { | ||
| return generateAuditBundle(graph, options); | ||
| } | ||
| if (target === "domain-coverage") { | ||
@@ -32,0 +36,0 @@ return generateDomainCoverage(graph, options); |
@@ -8,2 +8,3 @@ export function recommendedVerificationTargets(...args: any[]): any; | ||
| export function ensureContextSelection(...args: any[]): any; | ||
| export function featureById(...args: any[]): any; | ||
| export function getJourneyDoc(...args: any[]): any; | ||
@@ -38,2 +39,3 @@ export function getStatement(...args: any[]): any; | ||
| export function summarizeDomain(...args: any[]): any; | ||
| export function summarizeFeature(...args: any[]): any; | ||
| export function summarizeJourneyLikeByIds(...args: any[]): any; | ||
@@ -40,0 +42,0 @@ export function summarizePitch(...args: any[]): any; |
@@ -55,2 +55,3 @@ // @ts-check | ||
| relatedProjectionsForDomain, | ||
| featureById, | ||
| pitchById, | ||
@@ -63,2 +64,3 @@ requirementById, | ||
| documentById, | ||
| summarizeFeature, | ||
| summarizePitch, | ||
@@ -65,0 +67,0 @@ summarizeRequirement, |
@@ -143,2 +143,10 @@ import { stableSortedStrings } from "./primitives.js"; | ||
| */ | ||
| export function featureById(graph, id) { | ||
| return (graph?.byKind?.feature || []).find(/** @param {any} s */ (s) => s.id === id) || null; | ||
| } | ||
| /** | ||
| * @param {import("./types.d.ts").ContextGraph} graph | ||
| * @param {string} id | ||
| * @returns {any} | ||
| */ | ||
| export function planById(graph, id) { | ||
@@ -219,2 +227,5 @@ return (graph?.byKind?.plan || []).find(/** @param {any} s */ (s) => s.id === id) || null; | ||
| work_type: task.workType, | ||
| feature: task.feature?.id || null, | ||
| phase: task.phase || null, | ||
| scope: task.scope || null, | ||
| disposition: task.disposition || null, | ||
@@ -226,2 +237,22 @@ claimed_by: (task.claimedBy || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean), | ||
| /** | ||
| * @param {any} feature | ||
| * @returns {any} | ||
| */ | ||
| export function summarizeFeature(feature) { | ||
| if (!feature) return null; | ||
| return { | ||
| id: feature.id, | ||
| name: feature.name, | ||
| status: feature.status, | ||
| intent: feature.intent || null, | ||
| entities: (feature.entities || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(), | ||
| capabilities: (feature.capabilities || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(), | ||
| endpoints: (feature.endpoints || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(), | ||
| seed_data: (feature.seedData || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(), | ||
| verification_refs: (feature.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(), | ||
| tasks: (feature.tasks || []).slice().sort(), | ||
| domain: feature.resolvedDomain ? feature.resolvedDomain.id : null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {any} plan | ||
@@ -228,0 +259,0 @@ * @returns {any} |
@@ -437,2 +437,3 @@ import { groupBy, refIds, stableSortedStrings } from "./primitives.js"; | ||
| domains: stableSortedStrings((graph.byKind.domain || []).map(/** @param {any} item */ (item) => item.id)), | ||
| features: stableSortedStrings((graph.byKind.feature || []).map(/** @param {any} item */ (item) => item.id)), | ||
| pitches: stableSortedStrings((graph.byKind.pitch || []).map(/** @param {any} item */ (item) => item.id)), | ||
@@ -464,2 +465,3 @@ requirements: stableSortedStrings((graph.byKind.requirement || []).map(/** @param {any} item */ (item) => item.id)), | ||
| options.domainId ? ["domain", options.domainId] : null, | ||
| options.featureId ? ["feature", options.featureId] : null, | ||
| options.pitchId ? ["pitch", options.pitchId] : null, | ||
@@ -476,3 +478,3 @@ options.requirementId ? ["requirement", options.requirementId] : null, | ||
| throw new Error( | ||
| "Context selection requires exactly one of --capability, --workflow, --surface, --screen, --layout, --region, --component-map, --widget, --entity, --journey, --domain, --pitch, --requirement, --acceptance, --task, --plan, --bug, or --document" | ||
| "Context selection requires exactly one of --capability, --workflow, --surface, --screen, --layout, --region, --component-map, --widget, --entity, --journey, --domain, --feature, --pitch, --requirement, --acceptance, --task, --plan, --bug, or --document" | ||
| ); | ||
@@ -479,0 +481,0 @@ } |
@@ -238,2 +238,18 @@ import { | ||
| return summarizeWorkflow(statement); | ||
| case "feature": | ||
| return { | ||
| id: statement.id, | ||
| kind: statement.kind, | ||
| name: statement.name || statement.id, | ||
| description: statement.description || null, | ||
| intent: statement.intent || null, | ||
| status: statement.status || null, | ||
| entities: refIds(statement.entities), | ||
| capabilities: refIds(statement.capabilities), | ||
| endpoints: refIds(statement.endpoints), | ||
| seed_data: refIds(statement.seedData), | ||
| verification_refs: refIds(statement.verificationRefs), | ||
| tasks: stableSortedStrings(statement.tasks || []), | ||
| ownership_boundary: defaultOwnershipBoundary() | ||
| }; | ||
| case "shape": | ||
@@ -240,0 +256,0 @@ return { |
@@ -148,2 +148,3 @@ export type ContextReference = { | ||
| domainId?: string | null; | ||
| featureId?: string | null; | ||
| pitchId?: string | null; | ||
@@ -150,0 +151,0 @@ requirementId?: string | null; |
@@ -36,3 +36,12 @@ // @ts-check | ||
| import { applySliceDetailLevel, buildSliceManifest, normalizeSliceDetailLevel } from "./manifest.js"; | ||
| import { applyModePacketProfile, buildAgentGuidance, normalizedMode } from "./packet-profile.js"; | ||
| import { | ||
| buildSliceAttentionBudget, | ||
| buildSliceFrame, | ||
| buildSliceImplementationContracts, | ||
| buildSliceProofPlan, | ||
| buildSliceRelationships, | ||
| buildSliceWorkItems | ||
| } from "./cockpit.js"; | ||
| import { | ||
| acceptanceCriterionSlice, | ||
@@ -42,2 +51,3 @@ bugSlice, | ||
| domainSlice, | ||
| featureSlice, | ||
| journeySlice, | ||
@@ -427,148 +437,2 @@ pitchSlice, | ||
| /** | ||
| * @param {string|null|undefined} modeId | ||
| * @returns {string} | ||
| */ | ||
| function normalizedMode(modeId) { | ||
| if (modeId === "maintained-app-edit") return "maintained-app"; | ||
| if (modeId === "diff-review") return "review"; | ||
| return modeId || "implementation"; | ||
| } | ||
| /** | ||
| * @param {any} focus | ||
| * @returns {string} | ||
| */ | ||
| function selectorForFocus(focus) { | ||
| if (focus?.kind === "screen") { | ||
| return `${focus.projectionId ? `--surface ${focus.projectionId} ` : ""}--screen ${focus.id || "<id>"}`; | ||
| } | ||
| if (focus?.kind === "widget") { | ||
| return `${widgetScopeSelector(focus.scope)}--widget ${focus.id || "<id>"}`.trim(); | ||
| } | ||
| /** @type {Record<string, string>} */ | ||
| const flagByKind = { | ||
| capability: "--capability", | ||
| workflow: "--workflow", | ||
| screen: "--screen", | ||
| layout: "--layout", | ||
| region: "--region", | ||
| component_map: "--component-map", | ||
| widget: "--widget", | ||
| entity: "--entity", | ||
| journey: "--journey", | ||
| surface: "--surface", | ||
| domain: "--domain", | ||
| pitch: "--pitch", | ||
| requirement: "--requirement", | ||
| acceptance_criterion: "--acceptance", | ||
| task: "--task", | ||
| plan: "--plan", | ||
| bug: "--bug", | ||
| document: "--document" | ||
| }; | ||
| const flag = flagByKind[focus?.kind] || "--id"; | ||
| const projectionScope = focus?.projectionId && focus.kind !== "surface" ? `--surface ${focus.projectionId} ` : ""; | ||
| return `${projectionScope}${flag} ${focus?.id || "<id>"}`; | ||
| } | ||
| /** | ||
| * @param {any} scope | ||
| * @returns {string} | ||
| */ | ||
| function widgetScopeSelector(scope = {}) { | ||
| const flags = [ | ||
| scope?.projectionId ? `--surface ${scope.projectionId}` : null, | ||
| scope?.screenId ? `--screen ${scope.screenId}` : null, | ||
| scope?.layoutId ? `--layout ${scope.layoutId}` : null, | ||
| scope?.regionId ? `--region ${scope.regionId}` : null, | ||
| scope?.componentMapId ? `--component-map ${scope.componentMapId}` : null | ||
| ].filter(Boolean); | ||
| return flags.length > 0 ? `${flags.join(" ")} ` : ""; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {string} | ||
| */ | ||
| function detailFlag(detailLevel) { | ||
| return detailLevel && detailLevel !== "standard" ? ` --detail ${detailLevel}` : ""; | ||
| } | ||
| /** | ||
| * @param {string} mode | ||
| * @param {string} selector | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {string} | ||
| */ | ||
| function sliceQueryCommand(mode, selector, detailLevel) { | ||
| return `topogram query slice ./topo --mode ${mode} ${selector}${detailFlag(detailLevel)} --json`; | ||
| } | ||
| /** | ||
| * @param {string} selector | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {string} | ||
| */ | ||
| function emitSliceCommand(selector, detailLevel) { | ||
| return `topogram emit context-slice ./topo ${selector}${detailFlag(detailLevel)} --json`; | ||
| } | ||
| /** | ||
| * @param {any} slice | ||
| * @param {string|null|undefined} modeId | ||
| * @param {string|null|undefined} detailLevel | ||
| * @returns {any} | ||
| */ | ||
| function buildAgentGuidance(slice, modeId, detailLevel = "standard") { | ||
| const mode = normalizedMode(modeId); | ||
| const selector = selectorForFocus(slice.focus); | ||
| const commonCommands = [ | ||
| "topogram check . --json", | ||
| "topogram sdlc check . --strict", | ||
| "topogram sdlc prep commit . --json" | ||
| ]; | ||
| /** @type {Record<string, string[]>} */ | ||
| const modeCommands = { | ||
| modeling: [sliceQueryCommand("modeling", selector, detailLevel)], | ||
| implementation: ["topogram query sdlc-proof-gaps ./topo " + (slice.focus?.kind === "task" ? `--task ${slice.focus.id}` : "--json")], | ||
| review: ["topogram query review-packet ./topo --mode review " + selector + " --json"], | ||
| verification: ["topogram query verification-targets ./topo --mode verification " + selector + " --json"], | ||
| "extract-adopt": ["topogram extract plan . --json", "topogram adopt --list . --json"], | ||
| "maintained-app": [emitSliceCommand(selector, detailLevel)], | ||
| "generated-app": ["topogram generate .", "npm run verify"], | ||
| release: ["topogram release status --strict --json"] | ||
| }; | ||
| const warnings = []; | ||
| if (mode === "maintained-app") { | ||
| warnings.push("Do not overwrite maintained app output with generation; use emitted contracts and focused queries as implementation context."); | ||
| } | ||
| if (mode === "generated-app") { | ||
| warnings.push("Generated-owned outputs may be refreshed by topogram generate; edit the Topogram source first."); | ||
| } | ||
| if (mode === "extract-adopt") { | ||
| warnings.push("Extraction candidates are review-only until explicitly adopted."); | ||
| } | ||
| return { | ||
| mode, | ||
| read_first: ["focus", "summary", "depends_on", "related", "standing_rules", "verification_targets", "write_scope"], | ||
| read_order: ["focus", "summary", "depends_on", "related", "standing_rules", "verification_targets", "write_scope"], | ||
| next_queries: [ | ||
| sliceQueryCommand(mode, selector, detailLevel), | ||
| `topogram query single-agent-plan ./topo --mode ${mode} ${selector} --json` | ||
| ], | ||
| required_commands: [...(modeCommands[mode] || []), ...commonCommands], | ||
| next_commands: [ | ||
| sliceQueryCommand(mode, selector, detailLevel), | ||
| `topogram query single-agent-plan ./topo --mode ${mode} ${selector} --json`, | ||
| ...(modeCommands[mode] || []), | ||
| ...commonCommands | ||
| ], | ||
| proof_commands: commonCommands, | ||
| completion_command: "topogram sdlc prep commit . --json", | ||
| warnings, | ||
| write_scope_summary: slice.write_scope?.summary || "Edit the canonical Topogram source and project-owned files only; generated-owned outputs should be regenerated." | ||
| }; | ||
| } | ||
| /** | ||
| * @param {import("../shared/types.d.ts").ContextGraph} graph | ||
@@ -583,2 +447,3 @@ * @param {any} slice | ||
| const detailLevel = normalizeSliceDetailLevel(options.detailLevel || options.detailId); | ||
| const agentGuidance = buildAgentGuidance(slice, options.modeId, detailLevel); | ||
| const decorated = { | ||
@@ -595,11 +460,45 @@ ...slice, | ||
| standing_rules: summarizeStatementsByIds(graph, standingRules), | ||
| agent_guidance: buildAgentGuidance(slice, options.modeId, detailLevel) | ||
| agent_guidance: agentGuidance | ||
| }; | ||
| const detailed = applySliceDetailLevel(decorated, detailLevel); | ||
| const sliceManifest = buildSliceManifest(detailed); | ||
| return { | ||
| const relationships = buildSliceRelationships(graph, detailed); | ||
| const workItems = buildSliceWorkItems(graph, detailed, relationships); | ||
| const implementationContracts = buildSliceImplementationContracts(graph, detailed); | ||
| const proofPlan = buildSliceProofPlan(detailed, detailed.agent_guidance || {}); | ||
| const enriched = /** @type {any} */ ({ | ||
| ...detailed, | ||
| frame: buildSliceFrame(detailed, proofPlan, detailed.agent_guidance || {}), | ||
| relationships, | ||
| work_items: workItems, | ||
| implementation_contracts: implementationContracts, | ||
| proof_plan: proofPlan | ||
| }); | ||
| const preliminaryManifest = buildSliceManifest(enriched); | ||
| const withGuidance = { | ||
| ...enriched, | ||
| agent_guidance: { | ||
| ...(detailed.agent_guidance || {}), | ||
| read_first: sliceManifest.read_order, | ||
| ...(enriched.agent_guidance || {}), | ||
| read_order: preliminaryManifest.read_order | ||
| }, | ||
| slice_manifest: preliminaryManifest | ||
| }; | ||
| const profiled = applyModePacketProfile(withGuidance, normalizedMode(options.modeId), detailLevel); | ||
| const profiledManifest = buildSliceManifest(profiled); | ||
| const profiledWithManifest = { | ||
| ...profiled, | ||
| agent_guidance: { | ||
| ...(profiled.agent_guidance || {}), | ||
| read_order: profiledManifest.read_order | ||
| }, | ||
| slice_manifest: profiledManifest | ||
| }; | ||
| const withBudget = { | ||
| ...profiledWithManifest, | ||
| attention_budget: buildSliceAttentionBudget(profiledWithManifest) | ||
| }; | ||
| const sliceManifest = buildSliceManifest(withBudget); | ||
| return { | ||
| ...withBudget, | ||
| agent_guidance: { | ||
| ...(withBudget.agent_guidance || {}), | ||
| read_order: sliceManifest.read_order | ||
@@ -653,2 +552,3 @@ }, | ||
| domainId: options.domainId, | ||
| featureId: options.featureId, | ||
| pitchId: options.pitchId, | ||
@@ -674,2 +574,3 @@ requirementId: options.requirementId, | ||
| if (selection.kind === "domain") slice = domainSlice(graph, selection.id); | ||
| if (selection.kind === "feature") slice = featureSlice(graph, selection.id); | ||
| if (selection.kind === "pitch") slice = pitchSlice(graph, selection.id); | ||
@@ -676,0 +577,0 @@ if (selection.kind === "requirement") slice = requirementSlice(graph, selection.id); |
@@ -54,5 +54,9 @@ // @ts-check | ||
| section(slice, "focus", "Focus", "must_read", "The selected graph surface this packet is scoped to."), | ||
| section(slice, "frame", "Frame", "must_read", "Goal, current state, done condition, risk, and recommended next action."), | ||
| section(slice, "summary", "Summary", "must_read", "The shortest human-readable description of the selected work or surface."), | ||
| section(slice, "agent_guidance", "Agent Guidance", "must_read", "Mode, read order, follow-up queries, and required proof commands."), | ||
| section(slice, "work_items", "Work Items", "must_read", "Actionable records the agent should reason about before editing."), | ||
| section(slice, "implementation_contracts", "Implementation Contracts", "must_read", "Endpoint, response, seed, and proof contracts the implementation should satisfy."), | ||
| section(slice, "standing_rules", "Standing Rules", "must_read", "Repo-level laws that should shape implementation choices."), | ||
| section(slice, "relationships", "Relationships", "must_read", "Why related records are included and how they connect to the focus."), | ||
| section(slice, "terms", "Glossary Terms", "must_read", "Canonical vocabulary needed to interpret this slice.", "related.terms"), | ||
@@ -68,4 +72,7 @@ section(slice, "review_boundary", "Review Boundary", "must_read", "Automation and human-review expectations for this scope."), | ||
| section(slice, "alternates", "Alternates", "reference", "Alternate journey paths or implementation branches."), | ||
| section(slice, "attention_budget", "Attention Budget", "diagnostic", "Approximate token and byte estimates by slice section."), | ||
| section(slice, "proof_plan", "Proof Plan", "proof", "Required, recommended, expensive, and already-satisfied proof work."), | ||
| section(slice, "verification_targets", "Verification Targets", "proof", "Smallest verification set recommended for this change."), | ||
| section(slice, "verification", "Verification Records", "proof", "Verification records directly linked to this slice.") | ||
| section(slice, "verification", "Verification Records", "proof", "Verification records directly linked to this slice."), | ||
| section(slice, "omitted_sections", "Omitted Sections", "diagnostic", "Sections intentionally omitted from this compact packet, with follow-up queries.") | ||
| ].filter(nonNull); | ||
@@ -72,0 +79,0 @@ |
@@ -9,2 +9,3 @@ // @ts-check | ||
| domainById, | ||
| featureById, | ||
| getJourneyDoc, | ||
@@ -25,2 +26,3 @@ pitchById, | ||
| summarizeDomain, | ||
| summarizeFeature, | ||
| summarizePitch, | ||
@@ -318,2 +320,51 @@ summarizePlan, | ||
| * @param {import("../shared/types.d.ts").ContextGraph} graph | ||
| * @param {string} featureId | ||
| * @returns {any} | ||
| */ | ||
| export function featureSlice(graph, featureId) { | ||
| const feature = featureById(graph, featureId); | ||
| if (!feature) throw new Error(`No feature found with id '${featureId}'`); | ||
| const entities = (feature.entities || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const capabilities = (feature.capabilities || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const endpoints = (feature.endpoints || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const seedData = (feature.seedData || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const verificationRefs = (feature.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const tasks = (feature.tasks || []).slice().sort(); | ||
| const verifications = [...new Set([...verificationRefs, ...verificationIdsForTarget(graph, [featureId, ...entities, ...capabilities, ...endpoints])])].sort(); | ||
| return { | ||
| type: "context_slice", | ||
| version: 1, | ||
| focus: { kind: "feature", id: featureId }, | ||
| summary: summarizeFeature(feature), | ||
| depends_on: { | ||
| entities, | ||
| capabilities, | ||
| endpoints, | ||
| seed_data: seedData, | ||
| verification_refs: verificationRefs, | ||
| tasks, | ||
| verifications | ||
| }, | ||
| related: { | ||
| entities: summarizeStatementsByIds(graph, entities), | ||
| capabilities: summarizeStatementsByIds(graph, capabilities), | ||
| endpoints: summarizeStatementsByIds(graph, endpoints), | ||
| seed_data: summarizeStatementsByIds(graph, seedData), | ||
| verification_refs: summarizeStatementsByIds(graph, verificationRefs), | ||
| tasks: summarizeStatementsByIds(graph, tasks) | ||
| }, | ||
| verification: summarizeStatementsByIds(graph, verifications), | ||
| verification_targets: recommendedVerificationTargets(graph, [featureId, ...entities, ...capabilities, ...endpoints], { | ||
| rationale: "Feature slice points at verification covering the feature records." | ||
| }), | ||
| write_scope: buildDefaultWriteScope(), | ||
| review_boundary: reviewBoundaryForTask(), | ||
| ownership_boundary: defaultOwnershipBoundary() | ||
| }; | ||
| } | ||
| /** | ||
| * @param {import("../shared/types.d.ts").ContextGraph} graph | ||
| * @param {string} taskId | ||
@@ -326,2 +377,4 @@ * @returns {any} | ||
| const featureId = task.feature?.id || null; | ||
| const feature = featureId ? featureById(graph, featureId) : null; | ||
| const satisfies = (task.satisfies || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
@@ -332,5 +385,19 @@ const acRefs = (task.acceptanceRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const blocks = (task.blocks || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort(); | ||
| const affects = (task.affects || []).map(/** @param {any} a */ (a) => (typeof a === "string" ? a : a?.id)).filter(Boolean).sort(); | ||
| const featureRefs = feature | ||
| ? [ | ||
| ...(feature.entities || []), | ||
| ...(feature.capabilities || []), | ||
| ...(feature.endpoints || []), | ||
| ...(feature.seedData || []) | ||
| ].map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean) | ||
| : []; | ||
| const affects = [...new Set([ | ||
| ...(task.affects || []).map(/** @param {any} a */ (a) => (typeof a === "string" ? a : a?.id)).filter(Boolean), | ||
| ...featureRefs | ||
| ])].sort(); | ||
| const plans = (task.plans || []).slice().sort(); | ||
| const verifications = [...new Set([...verificationRefs, ...verificationIdsForTarget(graph, [taskId, ...affects, ...acRefs])])].sort(); | ||
| const featureVerificationRefs = feature | ||
| ? (feature.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean) | ||
| : []; | ||
| const verifications = [...new Set([...verificationRefs, ...featureVerificationRefs, ...verificationIdsForTarget(graph, [taskId, ...affects, ...acRefs])])].sort(); | ||
@@ -349,2 +416,3 @@ return { | ||
| plans, | ||
| feature: featureId, | ||
| affects, | ||
@@ -359,2 +427,3 @@ verifications | ||
| plans: summarizeStatementsByIds(graph, plans), | ||
| feature: feature ? [summarizeFeature(feature)] : [], | ||
| affects: summarizeStatementsByIds(graph, affects) | ||
@@ -361,0 +430,0 @@ }, |
@@ -24,3 +24,3 @@ // @ts-check | ||
| ...(input.focus.kind === "screen" && regions.length === 0 ? ["screen has no resolved regions"] : []), | ||
| ...(input.focus.kind === "screen" && widgets.length === 0 ? ["screen has no widget bindings"] : []) | ||
| ...(input.focus.kind === "screen" && widgets.length === 0 ? ["screen has no render entries"] : []) | ||
| ]; | ||
@@ -83,3 +83,3 @@ const humanReviewNeeded = [ | ||
| region: usage.region || usage.usage?.region || null, | ||
| reason: "Widget binding is the work leaf where data, action, region, and design obligations meet." | ||
| reason: "Screen render is the work leaf where data, action, region, and design obligations meet." | ||
| }); | ||
@@ -86,0 +86,0 @@ } |
@@ -67,3 +67,3 @@ // @ts-check | ||
| designIntent: "semantic_ui", | ||
| concreteSurfaceOwns: ["routes", "surface_hints"] | ||
| concreteSurfaceOwns: ["navpoints", "surface_hints"] | ||
| }, | ||
@@ -81,3 +81,3 @@ sharedProjection: sharedProjection | ||
| layout: screen.layout || null, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| regions: screen.regions || [], | ||
@@ -87,5 +87,5 @@ displayFields: screen.displayFields || [], | ||
| })), | ||
| routes: contractScreens.filter((screen) => screen.route).map(/** @param {any} screen */ (screen) => ({ | ||
| navpoints: contractScreens.filter((screen) => screen.navpoint || screen.route).map(/** @param {any} screen */ (screen) => ({ | ||
| screenId: screen.id, | ||
| path: screen.route | ||
| path: screen.navpoint || screen.route | ||
| })), | ||
@@ -182,3 +182,3 @@ widgets: contractScreens.flatMap(/** @param {any} screen */ (screen) => | ||
| requiredGates, | ||
| extraMissingContext: sourceUsages.length === 0 ? ["widget has no semantic_ui widget bindings"] : [] | ||
| extraMissingContext: sourceUsages.length === 0 ? ["widget has no screen render usage"] : [] | ||
| }) | ||
@@ -278,3 +278,3 @@ }; | ||
| designIntent: "semantic_ui", | ||
| concreteSurfaceOwns: ["screen_routes", "surface_hints"] | ||
| concreteSurfaceOwns: ["navpoint realization", "surface_hints"] | ||
| }, | ||
@@ -289,3 +289,3 @@ surface: { | ||
| workMap: screenWorkMap(screen), | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| regions: screen.regions || [], | ||
@@ -355,3 +355,3 @@ widgets, | ||
| ["web", "ios", "android"].includes(String(projection.type || "")) && | ||
| (/** @type {any[]} */ (projection.uiRoutes || [])).some((route) => route.screenId === screenId) | ||
| (/** @type {any[]} */ (projection.uiNavpoints || projection.uiRoutes || [])).some((navpoint) => navpoint.screenId === screenId) | ||
| ); | ||
@@ -358,0 +358,0 @@ return surface || owner || null; |
@@ -54,3 +54,3 @@ // @ts-check | ||
| })), | ||
| route: contractScreen?.route || null | ||
| navpoint: contractScreen?.navpoint || contractScreen?.route || null | ||
| }; | ||
@@ -57,0 +57,0 @@ } |
@@ -44,3 +44,3 @@ // @ts-check | ||
| kind: screen.kind || null, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| widgets | ||
@@ -135,3 +135,3 @@ }); | ||
| layout: screen.layout || null, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| regions, | ||
@@ -138,0 +138,0 @@ widgets |
@@ -100,3 +100,3 @@ // @ts-check | ||
| verification_targets: recommendedVerificationTargets(graph, [regionId, ...layoutIds, ...widgetIds, ...surfaceIds], { | ||
| rationale: "Region slices prove reusable semantic work areas, inherited screen placements, widget bindings, and design obligations." | ||
| rationale: "Region slices prove reusable semantic work areas, inherited screen placements, render entries, and design obligations." | ||
| }), | ||
@@ -103,0 +103,0 @@ ui_agent_packet: packet, |
@@ -115,2 +115,3 @@ import { resolveWorkspace } from "../resolver.js"; | ||
| target === "server-contract" || | ||
| target === "node-http-api-scaffold" || | ||
| target === "persistence-scaffold" || | ||
@@ -131,2 +132,3 @@ target === "hono-server" || | ||
| target === "context-task-mode" || | ||
| target === "audit-bundle" || | ||
| target === "domain-coverage" || | ||
@@ -136,3 +138,3 @@ target === "domain-list" || | ||
| ) { | ||
| return okResult(target, generateContextTarget(target, graph, options)); | ||
| return okResult(target, generateContextTarget(target, graph, { ...options, workspaceAst })); | ||
| } | ||
@@ -139,0 +141,0 @@ |
| import { renderGlossaryMarkdown } from "./glossary.js"; | ||
| import { sanitizePublicPayload } from "../public-paths.js"; | ||
| import { formatContextSliceHtml } from "./context/slice/html.js"; | ||
| import { formatUiDesignCoverageMarkdown } from "./surfaces/web/ui-design-coverage.js"; | ||
@@ -15,2 +17,10 @@ import { formatWorkMapReportMarkdown } from "./surfaces/web/work-map-report-markdown.js"; | ||
| if (result.target === "audit-bundle") { | ||
| const bundleId = result.artifact?.bundle_id || "workspace"; | ||
| return (result.artifact?.files || []).map((file) => ({ | ||
| path: `audit-bundle/${bundleId}/${file.path}`, | ||
| contents: file.contents | ||
| })); | ||
| } | ||
| if (result.target === "context-diff") { | ||
@@ -31,3 +41,21 @@ return [{ path: "context-diff.json", contents: result.artifact }]; | ||
| options.workflowId || | ||
| options.domainId || | ||
| options.featureId || | ||
| options.pitchId || | ||
| options.requirementId || | ||
| options.acceptanceId || | ||
| options.taskId || | ||
| options.planId || | ||
| options.bugId || | ||
| options.documentId || | ||
| "context"; | ||
| if (options.outputFormat === "html") { | ||
| const publicPayload = sanitizePublicPayload(result.artifact, { | ||
| projectRoot: options.projectRoot || options.cwd || null, | ||
| workspaceRoot: options.workspaceRoot || options.inputPath || null, | ||
| topogramRoot: options.workspaceRoot || options.inputPath || null, | ||
| cwd: options.cwd || null | ||
| }); | ||
| return [{ path: `${sliceId}.context-slice.html`, contents: formatContextSliceHtml(publicPayload) }]; | ||
| } | ||
| return [{ path: `${sliceId}.context-slice.json`, contents: result.artifact }]; | ||
@@ -205,2 +233,3 @@ } | ||
| result.target === "app-bundle" || | ||
| result.target === "node-http-api-scaffold" || | ||
| result.target === "persistence-scaffold" || | ||
@@ -207,0 +236,0 @@ result.target === "hono-server" || |
@@ -14,2 +14,5 @@ import { | ||
| import { | ||
| generateRuntimeE2eBundle | ||
| } from "./e2e.js"; | ||
| import { | ||
| generateRuntimeSmokeBundle | ||
@@ -51,2 +54,3 @@ } from "./smoke.js"; | ||
| const topology = resolveRuntimeTopology(graph, options); | ||
| const fullStack = topology.apiRuntimes.length > 0 && topology.webRuntimes.length > 0 && topology.dbRuntimes.length > 0; | ||
| const { apiProjection, uiProjection, dbProjection } = getDefaultEnvironmentProjections(graph, options); | ||
@@ -102,2 +106,3 @@ const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id, runtime: topology.primaryDb || undefined }) : null; | ||
| runtimeCheck: "./scripts/runtime-check.sh", | ||
| ...(fullStack ? { runtimeE2e: "./scripts/runtime-e2e-stack.sh" } : {}), | ||
| deployCheck: "./scripts/deploy-check.sh" | ||
@@ -110,2 +115,3 @@ }, | ||
| runtimeCheck: "runtime-check", | ||
| ...(fullStack ? { runtimeE2e: "runtime-e2e" } : {}), | ||
| compile: "compile", | ||
@@ -192,2 +198,13 @@ services: "apps/services", | ||
| }); | ||
| const runtimeE2eFeatureLine = plan.commands.runtimeE2e | ||
| ? "- `runtime-e2e/`: browser to API runtime proof for generated-owned full-stack apps\n" | ||
| : ""; | ||
| const runtimeE2eRunLines = plan.commands.runtimeE2e | ||
| ? `For browser to API proof, run: | ||
| - \`bash ${plan.commands.runtimeE2e.replace("./", "")}\` | ||
| ` | ||
| : ""; | ||
| const runtimeE2eNotes = plan.commands.runtimeE2e | ||
| ? "- `runtime-e2e/` starts a local stack and verifies the journey-backed browser route plus API readback\n" | ||
| : ""; | ||
| return `# ${plan.name} | ||
@@ -206,2 +223,3 @@ | ||
| - \`runtime-check/\`: richer staged runtime verification with JSON reporting | ||
| ${runtimeE2eFeatureLine} | ||
@@ -225,2 +243,3 @@ ## Start Here | ||
| - \`bash ${plan.commands.smoke.replace("./", "")}\` | ||
| ${runtimeE2eRunLines} | ||
@@ -255,21 +274,26 @@ ## Golden Path | ||
| - \`smoke/\` and \`runtime-check/\` are probes against a running local stack | ||
| - \`scripts/runtime.sh\` starts the local stack, waits for readiness, runs the probes, and stops the stack | ||
| ${runtimeE2eNotes}- \`scripts/runtime.sh\` starts the local stack, waits for readiness, runs the probes, and stops the stack | ||
| `; | ||
| } | ||
| function renderAppBundlePackageJson() { | ||
| function renderAppBundlePackageJson(includeRuntimeE2e = false) { | ||
| const scripts = { | ||
| check: "npm run compile", | ||
| bootstrap: "bash ./scripts/bootstrap.sh", | ||
| dev: "bash ./scripts/dev.sh", | ||
| compile: "bash ./scripts/compile-check.sh", | ||
| runtime: "bash ./scripts/runtime.sh", | ||
| "runtime-check": "bash ./scripts/runtime-check.sh", | ||
| smoke: "bash ./scripts/smoke.sh", | ||
| probe: "npm run smoke && npm run runtime-check", | ||
| "deploy:check": "bash ./scripts/deploy-check.sh" | ||
| }; | ||
| if (includeRuntimeE2e) { | ||
| scripts["runtime-e2e"] = "bash ./scripts/runtime-e2e-stack.sh"; | ||
| } | ||
| return `${JSON.stringify({ | ||
| name: "topogram-app-bundle", | ||
| private: true, | ||
| scripts: { | ||
| check: "npm run compile", | ||
| bootstrap: "bash ./scripts/bootstrap.sh", | ||
| dev: "bash ./scripts/dev.sh", | ||
| compile: "bash ./scripts/compile-check.sh", | ||
| runtime: "bash ./scripts/runtime.sh", | ||
| "runtime-check": "bash ./scripts/runtime-check.sh", | ||
| smoke: "bash ./scripts/smoke.sh", | ||
| probe: "npm run smoke && npm run runtime-check", | ||
| "deploy:check": "bash ./scripts/deploy-check.sh" | ||
| } | ||
| scripts, | ||
| ...(includeRuntimeE2e ? { devDependencies: { playwright: "^1.49.0" } } : {}) | ||
| }, null, 2)}\n`; | ||
@@ -291,2 +315,15 @@ } | ||
| function renderAppBundleRuntimeScript() { | ||
| return renderAppBundleRuntimeHarness([ | ||
| 'bash "$SCRIPT_DIR/smoke.sh"', | ||
| 'bash "$SCRIPT_DIR/runtime-check.sh"' | ||
| ]); | ||
| } | ||
| function renderAppBundleRuntimeE2eScript() { | ||
| return renderAppBundleRuntimeHarness([ | ||
| 'bash "$SCRIPT_DIR/runtime-e2e.sh"' | ||
| ]); | ||
| } | ||
| function renderAppBundleRuntimeHarness(probeLines) { | ||
| return `#!/usr/bin/env bash | ||
@@ -325,4 +362,3 @@ set -euo pipefail | ||
| node "$SCRIPT_DIR/wait-for-stack.mjs" | ||
| bash "$SCRIPT_DIR/smoke.sh" | ||
| bash "$SCRIPT_DIR/runtime-check.sh" | ||
| ${probeLines.join("\n")} | ||
| `; | ||
@@ -411,2 +447,6 @@ } | ||
| function renderAppBundleRuntimeE2eProbeScript() { | ||
| return renderNestedBundleShellScript("runtime-e2e", "scripts/e2e.sh"); | ||
| } | ||
| function renderAppBundleCompileScript() { | ||
@@ -443,2 +483,5 @@ return renderNestedBundleShellScript("compile", "scripts/check.sh"); | ||
| : noopBundle("Runtime Check", "No runtime check bundle is generated for this partial topology."); | ||
| const runtimeE2eBundle = fullStack | ||
| ? generateRuntimeE2eBundle(graph, options) | ||
| : noopBundle("Runtime E2E", "No runtime E2E bundle is generated for this partial topology."); | ||
| const compileBundle = generateCompileCheckBundle(graph, options); | ||
@@ -450,3 +493,3 @@ | ||
| "README.md": renderAppBundleReadme(plan), | ||
| "package.json": renderAppBundlePackageJson(), | ||
| "package.json": renderAppBundlePackageJson(fullStack), | ||
| "app-bundle-plan.json": `${JSON.stringify(plan, null, 2)}\n`, | ||
@@ -457,5 +500,7 @@ "scripts/load-env.sh": renderAppBundleLoadEnvScript(), | ||
| "scripts/runtime.sh": renderAppBundleRuntimeScript(), | ||
| "scripts/runtime-e2e-stack.sh": renderAppBundleRuntimeE2eScript(), | ||
| "scripts/wait-for-stack.mjs": renderAppBundleWaitForStackScript(plan), | ||
| "scripts/compile-check.sh": renderAppBundleCompileScript(), | ||
| "scripts/runtime-check.sh": renderAppBundleRuntimeCheckScript(), | ||
| "scripts/runtime-e2e.sh": renderAppBundleRuntimeE2eProbeScript(), | ||
| "scripts/smoke.sh": renderAppBundleSmokeScript(), | ||
@@ -470,2 +515,3 @@ "scripts/deploy-check.sh": renderAppBundleDeployCheckScript() | ||
| "runtime-check": runtimeCheckBundle, | ||
| "runtime-e2e": runtimeE2eBundle, | ||
| compile: compileBundle | ||
@@ -472,0 +518,0 @@ }); |
@@ -39,2 +39,21 @@ import { | ||
| const capabilityIds = (apiProjection?.realizes || []).map((ref) => ref.id); | ||
| const seededEntityIds = new Set((graph.byKind.seed_data || []).map((seed) => seed.entity?.id).filter(Boolean)); | ||
| const capabilityById = new Map((graph.byKind.capability || []).map((capability) => [capability.id, capability])); | ||
| const seededGet = capabilityIds | ||
| .map((capabilityId) => { | ||
| const capability = capabilityById.get(capabilityId); | ||
| const entityId = (capability?.persistenceContracts || []).find((contract) => contract.operation === "read")?.entity?.id || | ||
| capability?.reads?.[0]?.id || | ||
| null; | ||
| const contract = apiContracts[capabilityId] || generateApiContractGraph(graph, { capabilityId }); | ||
| return { capabilityId, contract, entityId }; | ||
| }) | ||
| .find((entry) => entry.contract?.endpoint?.method === "GET" && seededEntityIds.has(entry.entityId)); | ||
| if (seededGet) { | ||
| return { | ||
| capabilityId: seededGet.capabilityId, | ||
| method: seededGet.contract.endpoint.method, | ||
| path: seededGet.contract.endpoint.path | ||
| }; | ||
| } | ||
| for (const capabilityId of capabilityIds) { | ||
@@ -60,2 +79,25 @@ const contract = apiContracts[capabilityId] || generateApiContractGraph(graph, { capabilityId }); | ||
| function firstSeedReadback(graph, apiProbe) { | ||
| if (!apiProbe?.capabilityId) return null; | ||
| const capability = (graph.byKind.capability || []).find((entry) => entry.id === apiProbe.capabilityId); | ||
| const entityId = (capability?.persistenceContracts || []).find((contract) => contract.operation === "read")?.entity?.id || | ||
| capability?.reads?.[0]?.id || | ||
| null; | ||
| const seed = (graph.byKind.seed_data || []).find((entry) => entry.entity?.id === entityId && (entry.records || []).length > 0); | ||
| const record = seed?.records?.[0] || null; | ||
| if (!seed || !record) return null; | ||
| const preferredField = ["id", "name", "title"].find((field) => record.fields?.[field] != null); | ||
| const [fallbackField, fallbackValue] = Object.entries(record.fields || {}).find(([, value]) => value != null) || []; | ||
| const field = preferredField || fallbackField || null; | ||
| const value = preferredField ? record.fields[preferredField] : fallbackValue; | ||
| if (!field || value == null) return null; | ||
| return { | ||
| seedId: seed.id, | ||
| entityId, | ||
| recordId: record.id, | ||
| field, | ||
| value | ||
| }; | ||
| } | ||
| function dbEngineForProjection(dbProjection) { | ||
@@ -73,2 +115,3 @@ if (!dbProjection) return null; | ||
| const verification = buildVerificationSummary(graph, ["journey", "runtime", "smoke"]); | ||
| const apiProbe = firstApiProbe(graph, apiProjection); | ||
| return { | ||
@@ -99,7 +142,8 @@ type: "runtime_e2e_plan", | ||
| browserFlow: firstJourneyRoute(graph, uiProjection), | ||
| apiProbe: firstApiProbe(graph, apiProjection), | ||
| apiProbe, | ||
| seedReadback: firstSeedReadback(graph, apiProbe), | ||
| assertions: [ | ||
| "browser_page_loads", | ||
| "api_readback_succeeds", | ||
| ...(dbEngineForProjection(dbProjection) === "sqlite" ? ["sqlite_prisma_readiness"] : []) | ||
| ...(dbEngineForProjection(dbProjection) === "sqlite" ? ["sqlite_api_seed_readback"] : []) | ||
| ] | ||
@@ -114,3 +158,3 @@ }; | ||
| Run \`bash ./scripts/e2e.sh\` after starting the generated stack. The script opens the journey-backed web route with Playwright, calls the generated API, and performs a Prisma readiness assertion when SQLite is configured. | ||
| Run \`bash ./scripts/e2e.sh\` after starting the generated stack. The script opens the journey-backed web route with Playwright and calls the generated API. For SQLite-backed prototypes it verifies model seed data through API readback, keeping the proof at the generated runtime boundary instead of importing a second Prisma client. | ||
| `; | ||
@@ -194,2 +238,18 @@ } | ||
| } | ||
| const contentType = response.headers.get("content-type") || ""; | ||
| const body = contentType.includes("application/json") ? await response.json().catch(() => null) : await response.text(); | ||
| if (plan.seedReadback) { | ||
| const bodyText = JSON.stringify(body); | ||
| if (!bodyText.includes(String(plan.seedReadback.value))) { | ||
| throw new Error(\`API seed readback for \${plan.seedReadback.seedId} did not include \${plan.seedReadback.field}=\${JSON.stringify(plan.seedReadback.value)}\`); | ||
| } | ||
| report.database = { | ||
| ok: true, | ||
| engine: plan.surfaces.dbEngine, | ||
| assertion: "api_seed_readback", | ||
| seedId: plan.seedReadback.seedId, | ||
| entityId: plan.seedReadback.entityId, | ||
| field: plan.seedReadback.field | ||
| }; | ||
| } | ||
| report.api = { | ||
@@ -199,3 +259,4 @@ capabilityId: plan.apiProbe.capabilityId, | ||
| path: plan.apiProbe.path, | ||
| status: response.status | ||
| status: response.status, | ||
| seedReadback: plan.seedReadback ? { seedId: plan.seedReadback.seedId, field: plan.seedReadback.field } : null | ||
| }; | ||
@@ -209,19 +270,9 @@ } | ||
| } | ||
| if (!process.env[plan.env.databaseUrl]) { | ||
| report.database = { skipped: true, reason: "DATABASE_URL is not set" }; | ||
| if (report.database?.ok) { | ||
| return; | ||
| } | ||
| let PrismaClient; | ||
| try { | ||
| ({ PrismaClient } = await import("@prisma/client")); | ||
| } catch { | ||
| throw new Error("SQLite runtime-e2e requires @prisma/client to be installed in the proof runtime."); | ||
| if (plan.seedReadback) { | ||
| throw new Error("SQLite runtime-e2e expected API seed readback to prove database-backed behavior, but no matching seed data was observed."); | ||
| } | ||
| const prisma = new PrismaClient(); | ||
| try { | ||
| await prisma.$queryRaw\`SELECT 1\`; | ||
| report.database = { ok: true, engine: "sqlite" }; | ||
| } finally { | ||
| await prisma.$disconnect(); | ||
| } | ||
| report.database = { skipped: true, reason: "no model seed_data readback configured" }; | ||
| } | ||
@@ -228,0 +279,0 @@ |
@@ -89,3 +89,3 @@ import { generateSvelteKitApp } from "../surfaces/web/sveltekit.js"; | ||
| `data-topogram-screen="${screen.id}"`, | ||
| screen.routeId ? `data-topogram-route="${screen.routeId}"` : null, | ||
| screen.navpointId ? `data-topogram-navpoint="${screen.navpointId}"` : null, | ||
| "data-topogram-output-mode=\"usable_app\"", | ||
@@ -92,0 +92,0 @@ "data-topogram-copy=\"screen-intent\"", |
@@ -218,2 +218,6 @@ import { | ||
| function endpointRequiresAuth(contract) { | ||
| return (contract.endpoint.auth || "none") !== "none" || (contract.endpoint.authz || []).length > 0; | ||
| } | ||
| function pathParamValue(token) { | ||
@@ -356,3 +360,3 @@ if (typeof token !== "string") { | ||
| const requestHeaders = new Headers(headers); | ||
| if ((contract.endpoint.authz || []).length > 0 && authToken() && !requestHeaders.has("Authorization")) { | ||
| if (endpointRequiresAuth(contract) && authToken() && !requestHeaders.has("Authorization")) { | ||
| requestHeaders.set("Authorization", \`Bearer \${authToken()}\`); | ||
@@ -359,0 +363,0 @@ } |
@@ -14,2 +14,48 @@ export const DB_TARGETS = new Set([ | ||
| export const DB_GENERATED_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/; | ||
| function assertSafeDbIdentifier(label, value) { | ||
| if (!value || !DB_GENERATED_IDENTIFIER_PATTERN.test(String(value))) { | ||
| throw new Error(`${label} '${value || ""}' is not a safe generated DB identifier; expected ${DB_GENERATED_IDENTIFIER_PATTERN.source}`); | ||
| } | ||
| } | ||
| export function assertSafeDbSnapshot(snapshot) { | ||
| for (const table of snapshot.tables || []) { | ||
| assertSafeDbIdentifier("DB table", table.table); | ||
| for (const column of table.columns || []) { | ||
| assertSafeDbIdentifier(`DB column on ${table.table}`, column.name); | ||
| assertSafeDbIdentifier(`DB source field on ${table.table}`, column.sourceField); | ||
| } | ||
| for (const field of table.primaryKey || []) { | ||
| assertSafeDbIdentifier(`DB primary key field on ${table.table}`, field); | ||
| } | ||
| for (const fields of table.uniques || []) { | ||
| for (const field of fields) { | ||
| assertSafeDbIdentifier(`DB unique field on ${table.table}`, field); | ||
| } | ||
| } | ||
| for (const index of table.indexes || []) { | ||
| for (const field of index.fields || []) { | ||
| assertSafeDbIdentifier(`DB index field on ${table.table}`, field); | ||
| } | ||
| } | ||
| for (const relation of table.relations || []) { | ||
| assertSafeDbIdentifier(`DB relation field on ${table.table}`, relation.field); | ||
| if (relation.target?.field) { | ||
| assertSafeDbIdentifier(`DB relation target field on ${table.table}`, relation.target.field); | ||
| } | ||
| } | ||
| if (table.lifecycle?.softDelete?.field) { | ||
| assertSafeDbIdentifier(`DB soft-delete field on ${table.table}`, table.lifecycle.softDelete.field); | ||
| } | ||
| if (table.lifecycle?.timestamps?.createdAt) { | ||
| assertSafeDbIdentifier(`DB created-at field on ${table.table}`, table.lifecycle.timestamps.createdAt); | ||
| } | ||
| if (table.lifecycle?.timestamps?.updatedAt) { | ||
| assertSafeDbIdentifier(`DB updated-at field on ${table.table}`, table.lifecycle.timestamps.updatedAt); | ||
| } | ||
| } | ||
| } | ||
| export function getDbFamily(options = {}) { | ||
@@ -16,0 +62,0 @@ if (options.projectionId?.includes("sqlite") || options.projectionId === "proj_db_local") { |
| import { | ||
| buildDbProjectionContract, | ||
| assertSafeDbSnapshot, | ||
| dbProjectionCandidates, | ||
@@ -70,3 +71,3 @@ findEnumStatement, | ||
| return { | ||
| const snapshot = { | ||
| type: "db_schema_snapshot", | ||
@@ -80,2 +81,4 @@ surface: contract.surface, | ||
| }; | ||
| assertSafeDbSnapshot(snapshot); | ||
| return snapshot; | ||
| } | ||
@@ -82,0 +85,0 @@ |
@@ -7,3 +7,3 @@ import { generateBackendTarget } from "./services/index.js"; | ||
| export function generateAppTarget(target, graph, options = {}) { | ||
| if (target === "server-contract" || target === "persistence-scaffold" || target === "hono-server" || target === "express-server") { | ||
| if (target === "server-contract" || target === "node-http-api-scaffold" || target === "persistence-scaffold" || target === "hono-server" || target === "express-server") { | ||
| return generateBackendTarget(target, graph, options); | ||
@@ -10,0 +10,0 @@ } |
@@ -116,4 +116,5 @@ import Foundation | ||
| var headers = extraHeaders | ||
| let auth = endpoint["auth"] as? String ?? "none" | ||
| let authz = endpoint["authz"] as? [[String: Any]] ?? [] | ||
| if !authz.isEmpty, !authToken().isEmpty { | ||
| if (auth != "none" || !authz.isEmpty), !authToken().isEmpty { | ||
| if headers["Authorization"] == nil { | ||
@@ -120,0 +121,0 @@ headers["Authorization"] = "Bearer " + authToken() |
@@ -14,2 +14,3 @@ import { generateDbTarget } from "../databases/index.js"; | ||
| import { getBackendImplementation } from "./generic-backend.js"; | ||
| import { tsString } from "./literals.js"; | ||
@@ -50,2 +51,4 @@ function renderExpressServerHelpers() { | ||
| type AuthMode = "none" | "user" | "manager" | "admin" | string | null | undefined; | ||
| export function jsonError(error: unknown) { | ||
@@ -332,2 +335,24 @@ if (error instanceof HttpError) { | ||
| function hasAdminRole(principal: AuthPrincipal) { | ||
| return principal.isAdmin || principal.roles.has("admin"); | ||
| } | ||
| function enforceAuthMode(principal: AuthPrincipal, auth: AuthMode) { | ||
| const mode = auth || "none"; | ||
| if (mode === "none" || mode === "user") { | ||
| return; | ||
| } | ||
| if (mode === "manager") { | ||
| if (principal.roles.has("manager") || hasAdminRole(principal)) { | ||
| return; | ||
| } | ||
| throw new HttpError(403, "manager_required", "Bearer token requires manager access"); | ||
| } | ||
| if (mode === "admin") { | ||
| if (hasAdminRole(principal)) { | ||
| return; | ||
| } | ||
| throw new HttpError(403, "admin_required", "Bearer token requires admin access"); | ||
| } | ||
| throw new HttpError(500, "invalid_auth_mode", \`Unsupported route auth mode: \${mode}\`); | ||
| } | ||
| function hasClaim(principal: AuthPrincipal, claim: string | null | undefined, claimValue: string | null | undefined) { | ||
@@ -428,2 +453,3 @@ if (!claim) { | ||
| req: Request, | ||
| auth: AuthMode, | ||
| authz: ReadonlyArray<{ role?: string | null; permission?: string | null; claim?: string | null; claimValue?: string | null; ownership?: string | null; ownershipField?: string | null }>, | ||
@@ -445,2 +471,3 @@ authorizationContext?: AuthorizationContext | ||
| enforceAuthMode(envPrincipal.principal, auth); | ||
| await authorizeWithPrincipal(envPrincipal.principal, authz, authorizationContext, { allowHeuristicOwnership: true }); | ||
@@ -451,2 +478,3 @@ } | ||
| req: Request, | ||
| auth: AuthMode, | ||
| authz: ReadonlyArray<{ role?: string | null; permission?: string | null; claim?: string | null; claimValue?: string | null; ownership?: string | null; ownershipField?: string | null }>, | ||
@@ -465,2 +493,3 @@ authorizationContext?: AuthorizationContext | ||
| enforceAuthMode(principal, auth); | ||
| await authorizeWithPrincipal(principal, authz, authorizationContext); | ||
@@ -471,6 +500,7 @@ } | ||
| req: Request, | ||
| auth: AuthMode, | ||
| authz: ReadonlyArray<{ role?: string | null; permission?: string | null; claim?: string | null; claimValue?: string | null; ownership?: string | null; ownershipField?: string | null }>, | ||
| authorizationContext?: AuthorizationContext | ||
| ) { | ||
| if (!authz || authz.length === 0) { | ||
| if ((!auth || auth === "none") && (!authz || authz.length === 0)) { | ||
| return; | ||
@@ -481,7 +511,7 @@ } | ||
| if (profile === "bearer_demo") { | ||
| await authorizeWithBearerDemoProfile(req, authz, authorizationContext); | ||
| await authorizeWithBearerDemoProfile(req, auth, authz, authorizationContext); | ||
| return; | ||
| } | ||
| if (profile === "bearer_jwt_hs256") { | ||
| await authorizeWithBearerJwtHs256Profile(req, authz, authorizationContext); | ||
| await authorizeWithBearerJwtHs256Profile(req, auth, authz, authorizationContext); | ||
| return; | ||
@@ -513,2 +543,3 @@ } | ||
| req: Request, | ||
| auth: (typeof serverContract.routes)[number]["endpoint"]["auth"], | ||
| authz: (typeof serverContract.routes)[number]["endpoint"]["authz"], | ||
@@ -544,4 +575,4 @@ authorizationContext?: AuthorizationContext | ||
| }, | ||
| authorize: async (req, authz, authorizationContext) => { | ||
| await authorizeWithGeneratedAuthProfile(req, authz, authorizationContext); | ||
| authorize: async (req, auth, authz, authorizationContext) => { | ||
| await authorizeWithGeneratedAuthProfile(req, auth, authz, authorizationContext); | ||
| } | ||
@@ -555,3 +586,3 @@ }); | ||
| app.listen(port, () => { | ||
| console.log(\`${serviceName} listening on http://localhost:\${port}\`); | ||
| console.log(${tsString(serviceName)} + \` listening on http://localhost:\${port}\`); | ||
| }); | ||
@@ -653,3 +684,3 @@ `; | ||
| lines.push(""); | ||
| lines.push(` app.get("/health", (_req, res) => res.status(200).json({ ok: true, service: "${serviceName}" }));`); | ||
| lines.push(` app.get("/health", (_req, res) => res.status(200).json({ ok: true, service: ${tsString(serviceName)} }));`); | ||
| lines.push(""); | ||
@@ -659,6 +690,6 @@ lines.push(' app.get("/ready", async (_req, res) => {'); | ||
| lines.push(" await deps.ready?.();"); | ||
| lines.push(` return res.status(200).json({ ok: true, ready: true, service: "${serviceName}" });`); | ||
| lines.push(` return res.status(200).json({ ok: true, ready: true, service: ${tsString(serviceName)} });`); | ||
| lines.push(" } catch (error) {"); | ||
| lines.push(' const message = error instanceof Error ? error.message : "Readiness check failed";'); | ||
| lines.push(` return res.status(503).json({ ok: false, ready: false, service: "${serviceName}", message });`); | ||
| lines.push(` return res.status(503).json({ ok: false, ready: false, service: ${tsString(serviceName)}, message });`); | ||
| lines.push(" }"); | ||
@@ -669,3 +700,3 @@ lines.push(" });"); | ||
| for (const lookup of lookupRoutes) { | ||
| lines.push(` app.get("${lookup.route}", async (_req, res) => {`); | ||
| lines.push(` app.get(${tsString(lookup.route)}, async (_req, res) => {`); | ||
| lines.push(" try {"); | ||
@@ -688,2 +719,3 @@ lines.push(` const result = await deps.${dependencyName}.${lookup.repositoryMethod}();`); | ||
| const hasOwnershipAuthz = (route.endpoint.authz || []).some((rule) => rule.ownership && rule.ownership !== "none"); | ||
| const routeRequiresAuth = (route.endpoint.auth && route.endpoint.auth !== "none") || (route.endpoint.authz || []).length > 0; | ||
| const authLoaderVar = `loadAuthorizationResource${routeIndex}`; | ||
@@ -695,3 +727,3 @@ lines.push(` const ${routeVar} = serverContract.routes[${routeIndex}]!;`); | ||
| lines.push(` const input = buildInput(req, ${routeVar}, body);`); | ||
| if ((route.endpoint.authz || []).length > 0) { | ||
| if (routeRequiresAuth) { | ||
| if (hasOwnershipAuthz) { | ||
@@ -707,3 +739,3 @@ if (preconditionCapabilityIds.includes(route.capabilityId)) { | ||
| lines.push(' if (!deps.authorize) throw new HttpError(500, "authorization_handler_missing", "Missing authorization handler for protected route");'); | ||
| lines.push(` await deps.authorize(req, ${routeVar}.endpoint.authz, { capabilityId: ${routeVar}.capabilityId, input, ${hasOwnershipAuthz ? `loadResource: typeof ${authLoaderVar} === "function" ? ${authLoaderVar} : undefined` : "loadResource: undefined"} });`); | ||
| lines.push(` await deps.authorize(req, ${routeVar}.endpoint.auth, ${routeVar}.endpoint.authz, { capabilityId: ${routeVar}.capabilityId, input, ${hasOwnershipAuthz ? `loadResource: typeof ${authLoaderVar} === "function" ? ${authLoaderVar} : undefined` : "loadResource: undefined"} });`); | ||
| } | ||
@@ -726,4 +758,4 @@ if ((route.endpoint.preconditions || []).length > 0 || (route.endpoint.idempotency || []).length > 0) { | ||
| lines.push(` const artifact = await deps.${dependencyName}.${methodName}(input as unknown as ${toPascalCase(methodName)}Input);`); | ||
| lines.push(` res.setHeader("Content-Type", artifact.contentType || "${route.endpoint.download?.[0]?.media || "application/octet-stream"}");`); | ||
| lines.push(` res.setHeader("Content-Disposition", contentDisposition("${route.endpoint.download?.[0]?.disposition || "attachment"}", artifact.filename || "${route.endpoint.download?.[0]?.filename || "download.bin"}"));`); | ||
| lines.push(` res.setHeader("Content-Type", artifact.contentType || ${tsString(route.endpoint.download?.[0]?.media || "application/octet-stream")});`); | ||
| lines.push(` res.setHeader("Content-Disposition", contentDisposition(${tsString(route.endpoint.download?.[0]?.disposition || "attachment")}, artifact.filename || ${tsString(route.endpoint.download?.[0]?.filename || "download.bin")}));`); | ||
| lines.push(` return res.status(${route.successStatus}).send(artifact.body as any);`); | ||
@@ -734,12 +766,12 @@ } else { | ||
| const cacheRule = route.endpoint.cache[0]; | ||
| lines.push(` const etag = (result as unknown as Record<string, unknown>)["${cacheRule.source}"];`); | ||
| lines.push(` if (etag && req.get("${cacheRule.requestHeader}") === String(etag)) {`); | ||
| lines.push(` const etag = (result as unknown as Record<string, unknown>)[${tsString(cacheRule.source)}];`); | ||
| lines.push(` if (etag && req.get(${tsString(cacheRule.requestHeader)}) === String(etag)) {`); | ||
| lines.push(` return res.status(${cacheRule.notModified}).end();`); | ||
| lines.push(" }"); | ||
| lines.push(` if (etag) res.setHeader("${cacheRule.responseHeader}", String(etag));`); | ||
| lines.push(` if (etag) res.setHeader(${tsString(cacheRule.responseHeader)}, String(etag));`); | ||
| } | ||
| if ((route.endpoint.async || []).length > 0) { | ||
| const asyncRule = route.endpoint.async[0]; | ||
| lines.push(` res.setHeader("${asyncRule.locationHeader}", (result as unknown as Record<string, unknown>).status_url ? String((result as unknown as Record<string, unknown>).status_url) : "${asyncRule.statusPath}".replace(":job_id", String((result as unknown as Record<string, unknown>).job_id ?? "")));`); | ||
| lines.push(` res.setHeader("${asyncRule.retryAfterHeader}", "5");`); | ||
| lines.push(` res.setHeader(${tsString(asyncRule.locationHeader)}, (result as unknown as Record<string, unknown>).status_url ? String((result as unknown as Record<string, unknown>).status_url) : ${tsString(asyncRule.statusPath)}.replace(":job_id", String((result as unknown as Record<string, unknown>).job_id ?? "")));`); | ||
| lines.push(` res.setHeader(${tsString(asyncRule.retryAfterHeader)}, "5");`); | ||
| } | ||
@@ -746,0 +778,0 @@ if (responseMode === "item" || responseMode === "cursor" || responseMode === "paged" || responseMode === "collection") { |
@@ -16,2 +16,3 @@ import { generateDbTarget } from "../databases/index.js"; | ||
| import { toPascalCase } from "../databases/shared.js"; | ||
| import { tsString } from "./literals.js"; | ||
@@ -72,3 +73,3 @@ function renderServerAppTs(realization) { | ||
| lines.push(""); | ||
| lines.push(` app.get("/health", (c) => c.json({ ok: true, service: "${serviceName}" }, 200 as any));`); | ||
| lines.push(` app.get("/health", (c) => c.json({ ok: true, service: ${tsString(serviceName)} }, 200 as any));`); | ||
| lines.push(""); | ||
@@ -78,6 +79,6 @@ lines.push(' app.get("/ready", async (c) => {'); | ||
| lines.push(" await deps.ready?.();"); | ||
| lines.push(` return c.json({ ok: true, ready: true, service: "${serviceName}" }, 200 as any);`); | ||
| lines.push(` return c.json({ ok: true, ready: true, service: ${tsString(serviceName)} }, 200 as any);`); | ||
| lines.push(" } catch (error) {"); | ||
| lines.push(' const message = error instanceof Error ? error.message : "Readiness check failed";'); | ||
| lines.push(` return c.json({ ok: false, ready: false, service: "${serviceName}", message }, 503 as any);`); | ||
| lines.push(` return c.json({ ok: false, ready: false, service: ${tsString(serviceName)}, message }, 503 as any);`); | ||
| lines.push(" }"); | ||
@@ -88,3 +89,3 @@ lines.push(" });"); | ||
| for (const lookup of lookupRoutes) { | ||
| lines.push(` app.get("${lookup.route}", async (c) => {`); | ||
| lines.push(` app.get(${tsString(lookup.route)}, async (c) => {`); | ||
| lines.push(" try {"); | ||
@@ -107,2 +108,3 @@ lines.push(` const result = await deps.${dependencyName}.${lookup.repositoryMethod}();`); | ||
| const hasOwnershipAuthz = (route.endpoint.authz || []).some((rule) => rule.ownership && rule.ownership !== "none"); | ||
| const routeRequiresAuth = (route.endpoint.auth && route.endpoint.auth !== "none") || (route.endpoint.authz || []).length > 0; | ||
| const authLoaderVar = `loadAuthorizationResource${routeIndex}`; | ||
@@ -119,3 +121,3 @@ lines.push(` const ${routeVar} = serverContract.routes[${routeIndex}]!;`); | ||
| lines.push(` const input = buildInput(c, ${routeVar}, body);`); | ||
| if ((route.endpoint.authz || []).length > 0) { | ||
| if (routeRequiresAuth) { | ||
| if (hasOwnershipAuthz) { | ||
@@ -131,3 +133,3 @@ if (preconditionCapabilityIds.includes(route.capabilityId)) { | ||
| lines.push(' if (!deps.authorize) throw new HttpError(500, "authorization_handler_missing", "Missing authorization handler for protected route");'); | ||
| lines.push(` await deps.authorize(c, ${routeVar}.endpoint.authz, { capabilityId: ${routeVar}.capabilityId, input, ${hasOwnershipAuthz ? `loadResource: typeof ${authLoaderVar} === "function" ? ${authLoaderVar} : undefined` : "loadResource: undefined"} });`); | ||
| lines.push(` await deps.authorize(c, ${routeVar}.endpoint.auth, ${routeVar}.endpoint.authz, { capabilityId: ${routeVar}.capabilityId, input, ${hasOwnershipAuthz ? `loadResource: typeof ${authLoaderVar} === "function" ? ${authLoaderVar} : undefined` : "loadResource: undefined"} });`); | ||
| } | ||
@@ -151,4 +153,4 @@ if ((route.endpoint.preconditions || []).length > 0 || (route.endpoint.idempotency || []).length > 0) { | ||
| lines.push(" const responseHeaders = new Headers();"); | ||
| lines.push(` responseHeaders.set("Content-Type", artifact.contentType || "${route.endpoint.download?.[0]?.media || "application/octet-stream"}");`); | ||
| lines.push(` responseHeaders.set("Content-Disposition", contentDisposition("${route.endpoint.download?.[0]?.disposition || "attachment"}", artifact.filename || "${route.endpoint.download?.[0]?.filename || "download.bin"}"));`); | ||
| lines.push(` responseHeaders.set("Content-Type", artifact.contentType || ${tsString(route.endpoint.download?.[0]?.media || "application/octet-stream")});`); | ||
| lines.push(` responseHeaders.set("Content-Disposition", contentDisposition(${tsString(route.endpoint.download?.[0]?.disposition || "attachment")}, artifact.filename || ${tsString(route.endpoint.download?.[0]?.filename || "download.bin")}));`); | ||
| lines.push(` return new Response(artifact.body as BodyInit | null, { status: ${route.successStatus}, headers: responseHeaders });`); | ||
@@ -159,12 +161,12 @@ } else { | ||
| const cacheRule = route.endpoint.cache[0]; | ||
| lines.push(` const etag = (result as unknown as Record<string, unknown>)["${cacheRule.source}"];`); | ||
| lines.push(` if (etag && c.req.header("${cacheRule.requestHeader}") === String(etag)) {`); | ||
| lines.push(` const etag = (result as unknown as Record<string, unknown>)[${tsString(cacheRule.source)}];`); | ||
| lines.push(` if (etag && c.req.header(${tsString(cacheRule.requestHeader)}) === String(etag)) {`); | ||
| lines.push(` return c.body(null, ${cacheRule.notModified} as any);`); | ||
| lines.push(" }"); | ||
| lines.push(` if (etag) c.header("${cacheRule.responseHeader}", String(etag));`); | ||
| lines.push(` if (etag) c.header(${tsString(cacheRule.responseHeader)}, String(etag));`); | ||
| } | ||
| if ((route.endpoint.async || []).length > 0) { | ||
| const asyncRule = route.endpoint.async[0]; | ||
| lines.push(` c.header("${asyncRule.locationHeader}", (result as unknown as Record<string, unknown>).status_url ? String((result as unknown as Record<string, unknown>).status_url) : "${asyncRule.statusPath}".replace(":job_id", String((result as unknown as Record<string, unknown>).job_id ?? "")));`); | ||
| lines.push(` c.header("${asyncRule.retryAfterHeader}", "5");`); | ||
| lines.push(` c.header(${tsString(asyncRule.locationHeader)}, (result as unknown as Record<string, unknown>).status_url ? String((result as unknown as Record<string, unknown>).status_url) : ${tsString(asyncRule.statusPath)}.replace(":job_id", String((result as unknown as Record<string, unknown>).job_id ?? "")));`); | ||
| lines.push(` c.header(${tsString(asyncRule.retryAfterHeader)}, "5");`); | ||
| } | ||
@@ -171,0 +173,0 @@ if (responseMode === "item" || responseMode === "cursor" || responseMode === "paged" || responseMode === "collection") { |
| import { generateWithComponentGenerator } from "../../adapters.js"; | ||
| import { generateExpressServer } from "./express.js"; | ||
| import { generateHonoServer } from "./hono.js"; | ||
| import { generateNodeHttpApiScaffold } from "./node-http-api-scaffold.js"; | ||
| import { generatePersistenceScaffold } from "./persistence-wiring.js"; | ||
@@ -11,2 +12,5 @@ import { generateServerContract } from "./server-contract.js"; | ||
| } | ||
| if (target === "node-http-api-scaffold") { | ||
| return generateNodeHttpApiScaffold(graph, options); | ||
| } | ||
| if (target === "persistence-scaffold") { | ||
@@ -13,0 +17,0 @@ return generatePersistenceScaffold(graph, options); |
| import { toPascalCase } from "../databases/shared.js"; | ||
| import { getBackendImplementation } from "./generic-backend.js"; | ||
| import { tsString } from "./literals.js"; | ||
@@ -42,2 +43,4 @@ function lowerCamel(value) { | ||
| type AuthMode = "none" | "user" | "manager" | "admin" | string | null | undefined; | ||
| export function jsonError(error: unknown) { | ||
@@ -105,3 +108,3 @@ if (error instanceof HttpError) { | ||
| errors?: ReadonlyArray<{ code?: string; source?: string; status?: number }>; | ||
| requestContract?: { fields?: ReadonlyArray<{ name: string; required?: boolean }> }; | ||
| requestContract?: { fields?: ReadonlyArray<{ name: string; required?: boolean }> } | null; | ||
| }, | ||
@@ -325,2 +328,26 @@ input: Record<string, unknown> | ||
| function hasAdminRole(principal: AuthPrincipal) { | ||
| return principal.isAdmin || principal.roles.has("admin"); | ||
| } | ||
| function enforceAuthMode(principal: AuthPrincipal, auth: AuthMode) { | ||
| const mode = auth || "none"; | ||
| if (mode === "none" || mode === "user") { | ||
| return; | ||
| } | ||
| if (mode === "manager") { | ||
| if (principal.roles.has("manager") || hasAdminRole(principal)) { | ||
| return; | ||
| } | ||
| throw new HttpError(403, "manager_required", "Bearer token requires manager access"); | ||
| } | ||
| if (mode === "admin") { | ||
| if (hasAdminRole(principal)) { | ||
| return; | ||
| } | ||
| throw new HttpError(403, "admin_required", "Bearer token requires admin access"); | ||
| } | ||
| throw new HttpError(500, "invalid_auth_mode", \`Unsupported route auth mode: \${mode}\`); | ||
| } | ||
| function hasClaim(principal: AuthPrincipal, claim: string | null | undefined, claimValue: string | null | undefined) { | ||
@@ -421,2 +448,3 @@ if (!claim) { | ||
| c: Context, | ||
| auth: AuthMode, | ||
| authz: ReadonlyArray<{ role?: string | null; permission?: string | null; claim?: string | null; claimValue?: string | null; ownership?: string | null; ownershipField?: string | null }>, | ||
@@ -438,2 +466,3 @@ authorizationContext?: AuthorizationContext | ||
| enforceAuthMode(envPrincipal.principal, auth); | ||
| await authorizeWithPrincipal(envPrincipal.principal, authz, authorizationContext, { allowHeuristicOwnership: true }); | ||
@@ -444,2 +473,3 @@ } | ||
| c: Context, | ||
| auth: AuthMode, | ||
| authz: ReadonlyArray<{ role?: string | null; permission?: string | null; claim?: string | null; claimValue?: string | null; ownership?: string | null; ownershipField?: string | null }>, | ||
@@ -458,2 +488,3 @@ authorizationContext?: AuthorizationContext | ||
| enforceAuthMode(principal, auth); | ||
| await authorizeWithPrincipal(principal, authz, authorizationContext); | ||
@@ -464,6 +495,7 @@ } | ||
| c: Context, | ||
| auth: AuthMode, | ||
| authz: ReadonlyArray<{ role?: string | null; permission?: string | null; claim?: string | null; claimValue?: string | null; ownership?: string | null; ownershipField?: string | null }>, | ||
| authorizationContext?: AuthorizationContext | ||
| ) { | ||
| if (!authz || authz.length === 0) { | ||
| if ((!auth || auth === "none") && (!authz || authz.length === 0)) { | ||
| return; | ||
@@ -474,7 +506,7 @@ } | ||
| if (profile === "bearer_demo") { | ||
| await authorizeWithBearerDemoProfile(c, authz, authorizationContext); | ||
| await authorizeWithBearerDemoProfile(c, auth, authz, authorizationContext); | ||
| return; | ||
| } | ||
| if (profile === "bearer_jwt_hs256") { | ||
| await authorizeWithBearerJwtHs256Profile(c, authz, authorizationContext); | ||
| await authorizeWithBearerJwtHs256Profile(c, auth, authz, authorizationContext); | ||
| return; | ||
@@ -506,2 +538,3 @@ } | ||
| ctx: Context, | ||
| auth: (typeof serverContract.routes)[number]["endpoint"]["auth"], | ||
| authz: (typeof serverContract.routes)[number]["endpoint"]["authz"], | ||
@@ -538,4 +571,4 @@ authorizationContext?: AuthorizationContext | ||
| }, | ||
| authorize: async (ctx, authz, authorizationContext) => { | ||
| await authorizeWithGeneratedAuthProfile(ctx, authz, authorizationContext); | ||
| authorize: async (ctx, auth, authz, authorizationContext) => { | ||
| await authorizeWithGeneratedAuthProfile(ctx, auth, authz, authorizationContext); | ||
| } | ||
@@ -553,3 +586,3 @@ }); | ||
| console.log(\`${serviceName} listening on http://localhost:\${port}\`); | ||
| console.log(${tsString(serviceName)} + \` listening on http://localhost:\${port}\`); | ||
| `; | ||
@@ -556,0 +589,0 @@ } |
| import { generateApiContractGraph } from "../../api.js"; | ||
| import { getProjection } from "../shared.js"; | ||
| import { deriveApiProjection, getProjection } from "../shared.js"; | ||
| import { toPascalCase } from "../databases/shared.js"; | ||
@@ -17,2 +17,6 @@ | ||
| function hasTopLevelEndpoints(graph) { | ||
| return (graph.byKind.endpoint || []).some((endpoint) => endpoint.capability?.id); | ||
| } | ||
| function repositoryMethodName(capabilityId) { | ||
@@ -49,3 +53,7 @@ const base = capabilityId.replace(/^cap_/, ""); | ||
| endpoint: { | ||
| id: apiContract.endpoint.id || null, | ||
| auth: apiContract.endpoint.auth, | ||
| responseResult: apiContract.endpoint.responseResult || null, | ||
| responseEntity: apiContract.endpoint.responseEntity || null, | ||
| responseContainer: apiContract.endpoint.responseContainer || null, | ||
| authz: apiContract.endpoint.authz || [], | ||
@@ -74,5 +82,11 @@ preconditions: apiContract.endpoint.preconditions || [], | ||
| const output = {}; | ||
| for (const projection of apiProjectionCandidates(graph)) { | ||
| const candidates = apiProjectionCandidates(graph); | ||
| if (candidates.length === 0 && hasTopLevelEndpoints(graph)) { | ||
| const projection = deriveApiProjection(graph); | ||
| output[projection.id] = buildServerContract(graph, projection); | ||
| return output; | ||
| } | ||
| for (const projection of candidates) { | ||
| output[projection.id] = buildServerContract(graph, projection); | ||
| } | ||
| return output; | ||
@@ -79,0 +93,0 @@ } |
@@ -5,2 +5,3 @@ // @ts-check | ||
| import { generateServerContract } from "./server-contract.js"; | ||
| import { tsString } from "./literals.js"; | ||
@@ -46,6 +47,149 @@ function renderPackageJson(profile) { | ||
| function renderStatelessAuthHelpers(profile) { | ||
| const readAuthHeader = profile === "express" | ||
| ? 'const header = req.get("Authorization") || "";' | ||
| : 'const header = c.req.header("Authorization") || "";'; | ||
| return `class HttpError extends Error { | ||
| constructor( | ||
| public readonly status: number, | ||
| public readonly code: string, | ||
| message = code | ||
| ) { | ||
| super(message); | ||
| } | ||
| } | ||
| function csvValues(value: string) { | ||
| return value | ||
| .split(",") | ||
| .map((entry) => entry.trim()) | ||
| .filter(Boolean); | ||
| } | ||
| function readBoolean(value: string) { | ||
| return value === "true" || value === "1" || value === "yes"; | ||
| } | ||
| function readBearerToken(${profile === "express" ? "req: any" : "c: any"}) { | ||
| ${readAuthHeader} | ||
| const match = /^Bearer\\s+(.+)$/i.exec(header); | ||
| return match ? match[1] : ""; | ||
| } | ||
| function principalFromEnv() { | ||
| if ((process.env.TOPOGRAM_AUTH_PROFILE || "") !== "bearer_demo") { | ||
| return null; | ||
| } | ||
| const token = process.env.TOPOGRAM_AUTH_TOKEN || ""; | ||
| if (!token) { | ||
| return null; | ||
| } | ||
| return { | ||
| token, | ||
| principal: { | ||
| permissions: new Set(csvValues(process.env.TOPOGRAM_AUTH_PERMISSIONS || "")), | ||
| roles: new Set(csvValues(process.env.TOPOGRAM_AUTH_ROLES || process.env.TOPOGRAM_AUTH_ROLE || "")), | ||
| claims: (() => { | ||
| try { | ||
| const parsed = JSON.parse(process.env.TOPOGRAM_AUTH_CLAIMS || "{}"); | ||
| return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| })(), | ||
| isAdmin: readBoolean(process.env.TOPOGRAM_AUTH_ADMIN || "") | ||
| } | ||
| }; | ||
| } | ||
| function hasAdminRole(principal: any) { | ||
| return principal.isAdmin || principal.roles.has("admin"); | ||
| } | ||
| function enforceAuthMode(principal: any, auth: string) { | ||
| const mode = auth || "none"; | ||
| if (mode === "none" || mode === "user") { | ||
| return; | ||
| } | ||
| if (mode === "manager") { | ||
| if (principal.roles.has("manager") || hasAdminRole(principal)) { | ||
| return; | ||
| } | ||
| throw new HttpError(403, "manager_required", "Bearer token requires manager access"); | ||
| } | ||
| if (mode === "admin") { | ||
| if (hasAdminRole(principal)) { | ||
| return; | ||
| } | ||
| throw new HttpError(403, "admin_required", "Bearer token requires admin access"); | ||
| } | ||
| throw new HttpError(500, "invalid_auth_mode", \`Unsupported route auth mode: \${mode}\`); | ||
| } | ||
| function satisfiesAuthz(principal: any, authz: readonly any[]) { | ||
| if (!authz || authz.length === 0) { | ||
| return true; | ||
| } | ||
| for (const rule of authz) { | ||
| if (rule.ownership && rule.ownership !== "none") { | ||
| throw new HttpError(500, "authorization_resource_loader_missing", "Stateless generated routes cannot evaluate ownership authorization"); | ||
| } | ||
| const roleOk = !rule.role || principal.roles.has(rule.role); | ||
| const permissionOk = !rule.permission || principal.permissions.has("*") || principal.permissions.has(rule.permission); | ||
| const claimOk = !rule.claim || ( | ||
| principal.claims[rule.claim] != null && | ||
| (!rule.claimValue ? principal.claims[rule.claim] !== false && principal.claims[rule.claim] !== "" : String(principal.claims[rule.claim]) === String(rule.claimValue)) | ||
| ); | ||
| if (roleOk && permissionOk && claimOk) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| function authorize(${profile === "express" ? "req: any" : "c: any"}, endpoint: { auth?: string; authz?: readonly any[] }) { | ||
| const auth = endpoint.auth || "none"; | ||
| const authz = endpoint.authz || []; | ||
| if (auth === "none" && authz.length === 0) { | ||
| return; | ||
| } | ||
| const envPrincipal = principalFromEnv(); | ||
| if (!envPrincipal) { | ||
| throw new HttpError(500, "missing_auth_demo_token", "Missing TOPOGRAM_AUTH_TOKEN for bearer_demo auth profile"); | ||
| } | ||
| const token = readBearerToken(${profile === "express" ? "req" : "c"}); | ||
| if (!token) { | ||
| throw new HttpError(401, "missing_bearer_token", "Missing bearer token"); | ||
| } | ||
| if (token !== envPrincipal.token) { | ||
| throw new HttpError(401, "invalid_bearer_token", "Invalid bearer token"); | ||
| } | ||
| enforceAuthMode(envPrincipal.principal, auth); | ||
| if (!satisfiesAuthz(envPrincipal.principal, authz)) { | ||
| throw new HttpError(403, "forbidden", "Bearer token does not satisfy authorization requirements"); | ||
| } | ||
| } | ||
| function jsonError(error: unknown) { | ||
| if (error instanceof HttpError) { | ||
| return { status: error.status, body: { error: { code: error.code, message: error.message } } }; | ||
| } | ||
| return { status: 500, body: { error: { code: "internal_server_error", message: "Internal server error" } } }; | ||
| } | ||
| `; | ||
| } | ||
| function renderHonoIndex(projection, contract) { | ||
| const routes = (contract.routes || []).map((route) => { | ||
| const routes = (contract.routes || []).map((route, index) => { | ||
| const method = String(route.method || "GET").toLowerCase(); | ||
| return `app.${method}("${routePath(route.path)}", (c) => c.json({ ok: true, capability: "${route.capabilityId}", input: { params: c.req.param(), query: c.req.query() } }, ${route.successStatus || 200} as any));`; | ||
| return `const route${index} = ${JSON.stringify({ endpoint: route.endpoint }, null, 2)} as const; | ||
| app.${method}(${tsString(routePath(route.path))}, (c) => { | ||
| try { | ||
| authorize(c, route${index}.endpoint); | ||
| return c.json({ ok: true, capability: ${tsString(route.capabilityId)}, input: { params: c.req.param(), query: c.req.query() } }, ${route.successStatus || 200} as any); | ||
| } catch (error) { | ||
| const failure = jsonError(error); | ||
| return c.json(failure.body, failure.status as any); | ||
| } | ||
| });`; | ||
| }).join("\n"); | ||
@@ -55,6 +199,8 @@ return `import { serve } from "@hono/node-server"; | ||
| ${renderStatelessAuthHelpers("hono")} | ||
| const app = new Hono(); | ||
| app.get("/health", (c) => c.json({ ok: true, service: "${projection.id}" })); | ||
| app.get("/ready", (c) => c.json({ ok: true, ready: true, service: "${projection.id}" })); | ||
| app.get("/health", (c) => c.json({ ok: true, service: ${tsString(projection.id)} })); | ||
| app.get("/ready", (c) => c.json({ ok: true, ready: true, service: ${tsString(projection.id)} })); | ||
| ${routes} | ||
@@ -64,3 +210,3 @@ | ||
| serve({ fetch: app.fetch, port }); | ||
| console.log(\`${projection.id} listening on http://localhost:\${port}\`); | ||
| console.log(${tsString(projection.id)} + \` listening on http://localhost:\${port}\`); | ||
| `; | ||
@@ -74,13 +220,24 @@ } | ||
| function renderExpressIndex(projection, contract) { | ||
| const routes = (contract.routes || []).map((route) => { | ||
| const routes = (contract.routes || []).map((route, index) => { | ||
| const method = String(route.method || "GET").toLowerCase(); | ||
| return `app.${method}("${expressPath(route.path)}", (req, res) => res.status(${route.successStatus || 200}).json({ ok: true, capability: "${route.capabilityId}", input: { params: req.params, query: req.query } }));`; | ||
| return `const route${index} = ${JSON.stringify({ endpoint: route.endpoint }, null, 2)} as const; | ||
| app.${method}(${tsString(expressPath(route.path))}, (req, res) => { | ||
| try { | ||
| authorize(req, route${index}.endpoint); | ||
| return res.status(${route.successStatus || 200}).json({ ok: true, capability: ${tsString(route.capabilityId)}, input: { params: req.params, query: req.query } }); | ||
| } catch (error) { | ||
| const failure = jsonError(error); | ||
| return res.status(failure.status).json(failure.body); | ||
| } | ||
| });`; | ||
| }).join("\n"); | ||
| return `import express from "express"; | ||
| ${renderStatelessAuthHelpers("express")} | ||
| const app = express(); | ||
| app.use(express.json()); | ||
| app.get("/health", (_req, res) => res.json({ ok: true, service: "${projection.id}" })); | ||
| app.get("/ready", (_req, res) => res.json({ ok: true, ready: true, service: "${projection.id}" })); | ||
| app.get("/health", (_req, res) => res.json({ ok: true, service: ${tsString(projection.id)} })); | ||
| app.get("/ready", (_req, res) => res.json({ ok: true, ready: true, service: ${tsString(projection.id)} })); | ||
| ${routes} | ||
@@ -90,3 +247,3 @@ | ||
| app.listen(port, () => { | ||
| console.log(\`${projection.id} listening on http://localhost:\${port}\`); | ||
| console.log(${tsString(projection.id)} + \` listening on http://localhost:\${port}\`); | ||
| }); | ||
@@ -93,0 +250,0 @@ `; |
@@ -10,2 +10,3 @@ export const APP_TARGETS = new Set([ | ||
| "server-contract", | ||
| "node-http-api-scaffold", | ||
| "persistence-scaffold", | ||
@@ -58,2 +59,10 @@ "hono-server", | ||
| const ids = new Set(); | ||
| for (const endpoint of graph.byKind.endpoint || []) { | ||
| if (endpoint.capability?.id) { | ||
| ids.add(endpoint.capability.id); | ||
| } | ||
| } | ||
| if (ids.size > 0) { | ||
| return [...ids].sort(); | ||
| } | ||
| for (const projection of graph.byKind.surface || []) { | ||
@@ -60,0 +69,0 @@ if (projection.type !== "web") { |
@@ -71,5 +71,5 @@ // @ts-check | ||
| events: propEventProof(eventIds, []), | ||
| route_wiring: { | ||
| status: context?.routes?.size ? "not_checked" : "not_declared", | ||
| routes: context ? [...context.routes].filter(Boolean).sort() : [] | ||
| navpoint_wiring: { | ||
| status: context?.navpoints?.size ? "not_checked" : "not_declared", | ||
| navpoints: context ? [...context.navpoints].filter(Boolean).sort() : [] | ||
| }, | ||
@@ -119,3 +119,3 @@ message: "Structured component reference needs proof review." | ||
| const eventProof = propEventProof(eventIds, namesMentioned(source, eventIds)); | ||
| const routeProof = routeWiringProof(projectRoot, moduleRef, exportRef, context); | ||
| const navpointProof = navpointWiringProof(projectRoot, moduleRef, exportRef, context); | ||
| const exportStatus = exportProof(source, exportRef); | ||
@@ -125,3 +125,3 @@ const proved = exportStatus !== "missing" && | ||
| eventProof.missing.length === 0 && | ||
| routeProof.status !== "missing"; | ||
| navpointProof.status !== "missing"; | ||
@@ -131,3 +131,3 @@ return { | ||
| status: proved ? "proved" : "review_required", | ||
| reason: proved ? "component_reference_proved" : firstComponentProofReason(exportStatus, propProof, eventProof, routeProof), | ||
| reason: proved ? "component_reference_proved" : firstComponentProofReason(exportStatus, propProof, eventProof, navpointProof), | ||
| module_status: "exists", | ||
@@ -137,6 +137,6 @@ export_status: exportStatus, | ||
| events: eventProof, | ||
| route_wiring: routeProof, | ||
| navpoint_wiring: navpointProof, | ||
| message: proved | ||
| ? `Structured component reference '${moduleRef}' is proved against source.` | ||
| : componentProofMessage(moduleRef, exportStatus, propProof, eventProof, routeProof) | ||
| : componentProofMessage(moduleRef, exportStatus, propProof, eventProof, navpointProof) | ||
| }; | ||
@@ -226,9 +226,9 @@ } | ||
| */ | ||
| function routeWiringProof(projectRoot, moduleRef, exportRef, context) { | ||
| const routes = context ? [...context.routes].filter(Boolean).sort() : []; | ||
| if (routes.length === 0) { | ||
| return { status: "not_declared", routes, evidence: null }; | ||
| function navpointWiringProof(projectRoot, moduleRef, exportRef, context) { | ||
| const navpoints = context ? [...context.navpoints].filter(Boolean).sort() : []; | ||
| if (navpoints.length === 0) { | ||
| return { status: "not_declared", navpoints, evidence: null }; | ||
| } | ||
| if (moduleRef.split("/").includes("routes")) { | ||
| return { status: "wired", routes, evidence: { source: "component_module_path" } }; | ||
| return { status: "wired", navpoints, evidence: { source: "component_module_path" } }; | ||
| } | ||
@@ -241,6 +241,6 @@ const routeFiles = collectRouteFiles(projectRoot); | ||
| if (needles.some((needle) => content.includes(needle))) { | ||
| return { status: "wired", routes, evidence: { source: "route_source", file: file.relative } }; | ||
| return { status: "wired", navpoints, evidence: { source: "framework_route_source", file: file.relative } }; | ||
| } | ||
| } | ||
| return { status: "missing", routes, evidence: null }; | ||
| return { status: "missing", navpoints, evidence: null }; | ||
| } | ||
@@ -299,10 +299,10 @@ | ||
| * @param {AnyRecord} eventProof | ||
| * @param {AnyRecord} routeProof | ||
| * @param {AnyRecord} navpointProof | ||
| * @returns {string} | ||
| */ | ||
| function componentProofMessage(moduleRef, exportStatus, propProof, eventProof, routeProof) { | ||
| function componentProofMessage(moduleRef, exportStatus, propProof, eventProof, navpointProof) { | ||
| if (exportStatus === "missing") return `Structured component reference module '${moduleRef}' does not prove the named export.`; | ||
| if (propProof.missing.length > 0) return `Structured component reference module '${moduleRef}' is missing required prop evidence: ${propProof.missing.join(", ")}.`; | ||
| if (eventProof.missing.length > 0) return `Structured component reference module '${moduleRef}' is missing event evidence: ${eventProof.missing.join(", ")}.`; | ||
| if (routeProof.status === "missing") return `Structured component reference module '${moduleRef}' is not proved wired into a route.`; | ||
| if (navpointProof.status === "missing") return `Structured component reference module '${moduleRef}' is not proved wired into a navpoint-backed screen.`; | ||
| return `Structured component reference module '${moduleRef}' needs proof review.`; | ||
@@ -315,10 +315,10 @@ } | ||
| * @param {AnyRecord} eventProof | ||
| * @param {AnyRecord} routeProof | ||
| * @param {AnyRecord} navpointProof | ||
| * @returns {string} | ||
| */ | ||
| function firstComponentProofReason(exportStatus, propProof, eventProof, routeProof) { | ||
| function firstComponentProofReason(exportStatus, propProof, eventProof, navpointProof) { | ||
| if (exportStatus === "missing") return "component_export_missing"; | ||
| if (propProof.missing.length > 0) return "component_props_missing"; | ||
| if (eventProof.missing.length > 0) return "component_events_missing"; | ||
| if (routeProof.status === "missing") return "component_route_wiring_missing"; | ||
| if (navpointProof.status === "missing") return "component_navpoint_wiring_missing"; | ||
| return "component_reference_unproved"; | ||
@@ -325,0 +325,0 @@ } |
@@ -69,3 +69,3 @@ import { buildDesignIntentCoverage } from "./design-intent.js"; | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| message: `Screen '${screen.id}' has semantic style intent but the generated React page does not carry data-topogram-style-intent.`, | ||
@@ -80,5 +80,5 @@ suggested_fix: "Render semantic style intent as a data-topogram-style-intent marker; do not infer CSS from the contract." | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| message: `Screen '${screen.id}' has route '${screen.route}' but no React page was generated.`, | ||
| suggested_fix: "Check the React generator contract-complete route emission for this screen." | ||
| navpoint: screen.navpoint || screen.route, | ||
| message: `Screen '${screen.id}' has navpoint '${screen.navpoint || screen.route}' but no React page was generated.`, | ||
| suggested_fix: "Check the React generator contract-complete navpoint emission for this screen." | ||
| }); | ||
@@ -99,3 +99,3 @@ } | ||
| id: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| page: pagePath, | ||
@@ -202,3 +202,3 @@ rendered, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -216,3 +216,3 @@ pattern: support.pattern || null, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -229,3 +229,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -242,3 +242,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -255,3 +255,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -268,3 +268,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -280,3 +280,3 @@ widget: widgetId, | ||
| return { | ||
| routed_screens: screens.length, | ||
| navpoint_screens: screens.length, | ||
| rendered_screens: screens.filter((screen) => screen.rendered).length, | ||
@@ -356,3 +356,3 @@ implementation_screens: 0, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| message_key: message.key, | ||
@@ -385,3 +385,3 @@ message: `Screen '${screen.id}' declares message '${message.key}' but the generated React page does not contain its message marker.`, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| target: `${obligation.targetKind}:${obligation.targetId}`, | ||
@@ -388,0 +388,0 @@ message: `Screen '${screen.id}' declares accessibility obligation '${obligation.targetKind}:${obligation.targetId}' but the generated React page does not contain its accessibility marker.`, |
@@ -199,3 +199,3 @@ import { buildWebRealization } from "../../../realization/ui/index.js"; | ||
| `data-topogram-screen="${escapeAttr(screen.id)}"`, | ||
| screen.routeId ? `data-topogram-route="${escapeAttr(screen.routeId)}"` : null, | ||
| screen.navpointId ? `data-topogram-navpoint="${escapeAttr(screen.navpointId)}"` : null, | ||
| screen.layout?.id ? `data-topogram-layout="${escapeAttr(screen.layout.id)}"` : null, | ||
@@ -202,0 +202,0 @@ accessibilityAttributes(obligation, message, screenTitleText(screen)), |
@@ -235,5 +235,14 @@ function authTokenExpression(target) { | ||
| function requestTransport(contract: ApiContract) { | ||
| return (contract.requestContract as { transport?: { path?: any[]; query?: any[]; body?: any[] } } | null | undefined)?.transport || {}; | ||
| } | ||
| function endpointRequiresAuth(contract: ApiContract) { | ||
| return (contract.endpoint.auth || "none") !== "none" || (contract.endpoint.authz || []).length > 0; | ||
| } | ||
| function buildPath(contract: ApiContract, input: Record<string, unknown>) { | ||
| let path = contract.endpoint.path; | ||
| for (const field of contract.requestContract?.transport.path || []) { | ||
| const transport = requestTransport(contract); | ||
| for (const field of transport.path || []) { | ||
| const raw = input[field.name]; | ||
@@ -243,3 +252,3 @@ path = path.replace(\`:\${field.transport.wireName}\`, encodeURIComponent(String(raw ?? ""))); | ||
| const params = new URLSearchParams(); | ||
| for (const field of contract.requestContract?.transport.query || []) { | ||
| for (const field of transport.query || []) { | ||
| const raw = input[field.name]; | ||
@@ -261,10 +270,11 @@ if (raw !== undefined && raw !== null && raw !== "") { | ||
| } | ||
| if ((contract.endpoint.authz || []).length > 0 && authToken() && !headers.has("Authorization")) { | ||
| if (endpointRequiresAuth(contract) && authToken() && !headers.has("Authorization")) { | ||
| headers.set("Authorization", "Bearer " + authToken()); | ||
| } | ||
| let body: string | undefined; | ||
| if ((contract.requestContract?.transport.body || []).length > 0) { | ||
| const transport = requestTransport(contract); | ||
| if ((transport.body || []).length > 0) { | ||
| headers.set("content-type", "application/json"); | ||
| const payload: Record<string, unknown> = {}; | ||
| for (const field of contract.requestContract?.transport.body || []) { | ||
| for (const field of transport.body || []) { | ||
| if (input[field.name] !== undefined) { | ||
@@ -271,0 +281,0 @@ payload[field.transport.wireName] = input[field.name]; |
@@ -67,3 +67,3 @@ import { buildWebRealization } from "../../../realization/ui/index.js"; | ||
| `data-topogram-screen="${escapeAttr(screen.id)}"`, | ||
| screen.routeId ? `data-topogram-route="${escapeAttr(screen.routeId)}"` : null, | ||
| screen.navpointId ? `data-topogram-navpoint="${escapeAttr(screen.navpointId)}"` : null, | ||
| screen.layout?.id ? `data-topogram-layout="${escapeAttr(screen.layout.id)}"` : null, | ||
@@ -153,3 +153,3 @@ accessibilityAttributes(obligation, message, screenTitleText(screen)), | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| message_key: message.key, | ||
@@ -182,3 +182,3 @@ message: `Screen '${screen.id}' declares message '${message.key}' but the generated SvelteKit page does not contain its message marker.`, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| target: `${obligation.targetKind}:${obligation.targetId}`, | ||
@@ -361,3 +361,3 @@ message: `Screen '${screen.id}' declares accessibility obligation '${obligation.targetKind}:${obligation.targetId}' but the generated SvelteKit page does not contain its accessibility marker.`, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| message: `Screen '${screen.id}' has semantic style intent but the generated SvelteKit page does not carry data-topogram-style-intent.`, | ||
@@ -372,5 +372,5 @@ suggested_fix: "Render semantic style intent as a data-topogram-style-intent marker; do not infer CSS from the contract." | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| message: `Screen '${screen.id}' has route '${screen.route}' but no SvelteKit page was generated.`, | ||
| suggested_fix: "Check the SvelteKit generator contract-complete route emission for this screen." | ||
| navpoint: screen.navpoint || screen.route, | ||
| message: `Screen '${screen.id}' has navpoint '${screen.navpoint || screen.route}' but no SvelteKit page was generated.`, | ||
| suggested_fix: "Check the SvelteKit generator contract-complete navpoint emission for this screen." | ||
| }); | ||
@@ -422,3 +422,3 @@ } | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -436,3 +436,3 @@ pattern: support.pattern || null, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -449,3 +449,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -462,3 +462,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -475,3 +475,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -488,3 +488,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| region: usage.region || null, | ||
@@ -533,3 +533,3 @@ widget: widgetId, | ||
| id: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| page: pagePath, | ||
@@ -557,3 +557,3 @@ rendered, | ||
| summary: { | ||
| routed_screens: screens.length, | ||
| navpoint_screens: screens.length, | ||
| rendered_screens: screens.filter((screen) => screen.rendered).length, | ||
@@ -560,0 +560,0 @@ implementation_screens: screens.filter((screen) => screen.renderer === "implementation").length, |
@@ -132,3 +132,3 @@ // @ts-check | ||
| layout_pattern: screen.layout?.pattern || null, | ||
| route: screen.route || null | ||
| navpoint: screen.navpoint || screen.route || null | ||
| }, | ||
@@ -314,3 +314,3 @@ region: usage.region || null, | ||
| screens: new Set(), | ||
| routes: new Set(), | ||
| navpoints: new Set(), | ||
| layouts: new Set(), | ||
@@ -330,3 +330,3 @@ regions: new Set(), | ||
| entry?.screens.add(usage.screen?.id); | ||
| entry?.routes.add(usage.screen?.route); | ||
| entry?.navpoints.add(usage.screen?.navpoint || usage.screen?.route); | ||
| entry?.layouts.add(usage.screen?.layout?.id); | ||
@@ -400,3 +400,3 @@ entry?.layoutPatterns.add(usage.screen?.layout_pattern); | ||
| screens: context ? [...context.screens].filter(Boolean).sort() : [], | ||
| routes: context ? [...context.routes].filter(Boolean).sort() : [], | ||
| navpoints: context ? [...context.navpoints].filter(Boolean).sort() : [], | ||
| layouts: context ? [...context.layouts].filter(Boolean).sort() : [], | ||
@@ -463,3 +463,3 @@ layout_patterns: context ? [...context.layoutPatterns].filter(Boolean).sort() : [], | ||
| screens: context ? [...context.screens].filter(Boolean).sort() : [], | ||
| routes: context ? [...context.routes].filter(Boolean).sort() : [], | ||
| navpoints: context ? [...context.navpoints].filter(Boolean).sort() : [], | ||
| layouts: context ? [...context.layouts].filter(Boolean).sort() : [], | ||
@@ -466,0 +466,0 @@ layout_patterns: context ? [...context.layoutPatterns].filter(Boolean).sort() : [], |
@@ -72,3 +72,3 @@ // @ts-check | ||
| screen: screen.id, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| region: usage.region || null, | ||
@@ -88,3 +88,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| region: usage.region || null, | ||
@@ -99,3 +99,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| region: usage.region || null, | ||
@@ -220,3 +220,3 @@ widget: widgetId, | ||
| screen: screen.id, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| region: usage.region || null, | ||
@@ -237,3 +237,3 @@ widget: usage.widget || null, | ||
| screen: usage.screen, | ||
| route: usage.route, | ||
| navpoint: usage.navpoint, | ||
| region: usage.region, | ||
@@ -252,3 +252,3 @@ widget: usage.widget, | ||
| screen: usage.screen, | ||
| route: usage.route, | ||
| navpoint: usage.navpoint, | ||
| region: usage.region, | ||
@@ -271,3 +271,3 @@ widget: usage.widget, | ||
| screen: usage.screen, | ||
| route: usage.route, | ||
| navpoint: usage.navpoint, | ||
| region: usage.region, | ||
@@ -315,3 +315,3 @@ widget: usage.widget, | ||
| layout: screen.layout || null, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| regions: screen.regions || [], | ||
@@ -353,3 +353,3 @@ style: screen.style || null, | ||
| screens: screens.length, | ||
| routed_screens: screens.filter((screen) => screen.route).length, | ||
| navpoint_screens: screens.filter((screen) => screen.navpoint).length, | ||
| widget_usages: widgetUsages.length, | ||
@@ -389,3 +389,3 @@ rendered: widgetUsages.filter((usage) => usage.status === "rendered").length, | ||
| accessibility: "semantic_ui", | ||
| concreteSurfaceOwns: ["screen_routes", "surface_hints"] | ||
| concreteSurfaceOwns: ["navpoint realization", "surface_hints"] | ||
| }, | ||
@@ -392,0 +392,0 @@ designTokenMapping: designMappingReport(contract), |
@@ -49,3 +49,3 @@ import { buildWebRealization } from "../../../realization/ui/index.js"; | ||
| lines.push(""); | ||
| lines.push(`Route: ${screen.route ? `\`${screen.route}\`` : "_none_"}`); | ||
| lines.push(`Navpoint: ${screen.navpoint || screen.route ? `\`${screen.navpoint || screen.route}\`` : "_none_"}`); | ||
| lines.push(`Surface hints: ${Object.keys(screen.surfaceHints || {}).length > 0 ? Object.entries(screen.surfaceHints || {}).map(([key, value]) => `\`${key}=${value}\``).join(", ") : "_none_"}`); | ||
@@ -59,3 +59,3 @@ lines.push(""); | ||
| for (const entry of contract.sitemap) { | ||
| lines.push(`- ${entry.include ? "include" : "exclude"} \`${entry.route}\` (${entry.label})`); | ||
| lines.push(`- ${entry.include ? "include" : "exclude"} \`${entry.navpoint || entry.route}\` (${entry.label})`); | ||
| } | ||
@@ -62,0 +62,0 @@ lines.push(""); |
@@ -154,3 +154,3 @@ // @ts-check | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| message: `${generator} output for screen '${screen.id}' claims usable app output but missed '${code}'.`, | ||
@@ -166,3 +166,3 @@ suggested_fix | ||
| screen: screen.id, | ||
| route: screen.route, | ||
| navpoint: screen.navpoint || screen.route, | ||
| message: `${generator} output for screen '${screen.id}' still renders contract-preview scaffold copy.`, | ||
@@ -169,0 +169,0 @@ suggested_fix: "Replace generator provenance text with domain-specific copy derived from screen, capability, empty-state, and action contracts." |
@@ -6,2 +6,14 @@ // @ts-check | ||
| import { escapeAttr, escapeHtml } from "./html-escape.js"; | ||
| import { screenRegions } from "./screen-regions.js"; | ||
| import { | ||
| buildUsableScreenCoverage, | ||
| labelFromId, | ||
| sampleItemsForScreen, | ||
| screenEmptyState, | ||
| screenFormFields, | ||
| screenLeadText, | ||
| screenRequiresForm, | ||
| usableOutputSummary, | ||
| USABLE_APP_OUTPUT_MODE | ||
| } from "./usable-screen.js"; | ||
@@ -111,2 +123,93 @@ function slugify(value) { | ||
| .screen-shell { | ||
| display: grid; | ||
| gap: 1rem; | ||
| } | ||
| .screen-heading { | ||
| display: flex; | ||
| align-items: flex-start; | ||
| justify-content: space-between; | ||
| gap: 1rem; | ||
| } | ||
| .button-row { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 0.75rem; | ||
| } | ||
| button, | ||
| .action-button { | ||
| border: 0; | ||
| border-radius: var(--topogram-radius-control); | ||
| background: var(--topogram-action-primary-background); | ||
| color: var(--topogram-action-primary-color); | ||
| cursor: pointer; | ||
| font: inherit; | ||
| font-weight: 700; | ||
| padding: 0.7rem 0.95rem; | ||
| } | ||
| .resource-list { | ||
| display: grid; | ||
| gap: 0.75rem; | ||
| list-style: none; | ||
| margin: 0; | ||
| padding: 0; | ||
| } | ||
| .resource-row { | ||
| align-items: center; | ||
| border: 1px solid var(--topogram-border-color); | ||
| border-radius: var(--topogram-radius-card); | ||
| display: flex; | ||
| gap: 1rem; | ||
| justify-content: space-between; | ||
| padding: 0.85rem; | ||
| } | ||
| .resource-meta { | ||
| display: grid; | ||
| gap: 0.25rem; | ||
| } | ||
| .badge { | ||
| border-radius: 999px; | ||
| background: var(--topogram-surface-muted); | ||
| color: var(--topogram-muted-color); | ||
| font-size: 0.8rem; | ||
| padding: 0.25rem 0.55rem; | ||
| white-space: nowrap; | ||
| } | ||
| .field-grid { | ||
| display: grid; | ||
| gap: 0.85rem; | ||
| grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); | ||
| } | ||
| label { | ||
| display: grid; | ||
| gap: 0.35rem; | ||
| font-weight: 650; | ||
| } | ||
| input, | ||
| textarea { | ||
| border: 1px solid var(--topogram-border-color); | ||
| border-radius: var(--topogram-radius-control); | ||
| color: var(--topogram-text-color); | ||
| font: inherit; | ||
| padding: 0.65rem 0.75rem; | ||
| } | ||
| textarea { | ||
| min-height: 6rem; | ||
| } | ||
| .empty-state { | ||
| border-style: dashed; | ||
| } | ||
| .muted { | ||
@@ -124,9 +227,197 @@ color: var(--topogram-muted-color); | ||
| function renderBrowserScript() { | ||
| return `const stamp = document.querySelector("[data-generated-at]"); | ||
| if (stamp) { | ||
| stamp.textContent = new Date().toLocaleString(); | ||
| } | ||
| return `document.querySelectorAll("[data-topogram-action]").forEach((button) => { | ||
| button.addEventListener("click", () => { | ||
| const status = document.querySelector("[data-topogram-interaction-status]"); | ||
| if (status) { | ||
| status.textContent = \`\${button.textContent.trim()} is wired for prototype interaction.\`; | ||
| } | ||
| }); | ||
| }); | ||
| `; | ||
| } | ||
| function screenTitleText(screen) { | ||
| return screen.title || titleForScreen(screen.id); | ||
| } | ||
| function actionId(action) { | ||
| return action?.capability?.id || action?.id || null; | ||
| } | ||
| function actionName(action) { | ||
| return action?.capability?.name || action?.name || labelFromId(actionId(action)) || "Action"; | ||
| } | ||
| function screenActionEntries(screen) { | ||
| const actions = [ | ||
| screen.actions?.primary, | ||
| screen.actions?.secondary, | ||
| screen.actions?.destructive, | ||
| screen.actions?.terminal, | ||
| ...(screen.actions?.screen || []).map((entry) => entry.capability) | ||
| ].filter(Boolean); | ||
| const seen = new Set(); | ||
| return actions.filter((action) => { | ||
| const id = actionId(action); | ||
| if (!id || seen.has(id)) return false; | ||
| seen.add(id); | ||
| return true; | ||
| }); | ||
| } | ||
| function renderScreenActions(screen) { | ||
| const actions = screenActionEntries(screen); | ||
| if (actions.length === 0) return ""; | ||
| return `<div class="button-row"> | ||
| ${actions.map((action) => ` <button type="button" data-topogram-action="${escapeAttr(actionId(action))}">${escapeHtml(actionName(action))}</button>`).join("\n")} | ||
| </div>`; | ||
| } | ||
| function renderEmptyState(screen, titleText) { | ||
| const emptyState = screenEmptyState(screen, titleText); | ||
| return `<section class="panel empty-state" data-topogram-empty-state="screen"> | ||
| <h2>${escapeHtml(emptyState.title)}</h2> | ||
| <p class="muted">${escapeHtml(emptyState.body)}</p> | ||
| </section>`; | ||
| } | ||
| function renderFieldControl(field) { | ||
| const required = field.required ? " required" : ""; | ||
| if (field.inputType === "textarea") { | ||
| return `<textarea name="${escapeAttr(field.name)}"${required}></textarea>`; | ||
| } | ||
| return `<input name="${escapeAttr(field.name)}" type="${escapeAttr(field.inputType)}"${required} />`; | ||
| } | ||
| function renderForm(screen, titleText) { | ||
| const fields = screenFormFields(screen); | ||
| if (!screenRequiresForm(screen) || fields.length === 0) return ""; | ||
| return `<section class="panel" data-topogram-form="screen"> | ||
| <h2>Update ${escapeHtml(titleText)}</h2> | ||
| <div class="field-grid"> | ||
| ${fields.map((field) => ` <label data-topogram-display-field="${escapeAttr(field.name)}"> | ||
| <span>${escapeHtml(field.label)}</span> | ||
| ${renderFieldControl(field)} | ||
| </label>`).join("\n")} | ||
| </div> | ||
| </section>`; | ||
| } | ||
| function itemDisplayValue(item, field, fallback) { | ||
| const value = item?.[field?.name] ?? item?.[field?.label] ?? fallback; | ||
| return value == null ? "" : String(value); | ||
| } | ||
| function renderWidgetUsage(screen, usage, sampleItems) { | ||
| const widgetId = usage.widget?.id; | ||
| if (!widgetId) return ""; | ||
| const fields = (usage.displayFields || []).slice(0, 5); | ||
| const title = usage.widget?.name || labelFromId(widgetId) || widgetId; | ||
| const rows = sampleItems.slice(0, 4); | ||
| const fieldMarkup = fields.length > 0 | ||
| ? rows.map((item, index) => `<li class="resource-row"> | ||
| <div class="resource-meta"> | ||
| ${fields.map((field, fieldIndex) => ` <span data-topogram-display-field="${escapeAttr(field.name)}"${fieldIndex === 0 ? "" : " class=\"muted\""}>${escapeHtml(itemDisplayValue(item, field, index === 0 ? title : ""))}</span>`).join("\n")} | ||
| </div> | ||
| <span class="badge">${escapeHtml(item.status || "sample")}</span> | ||
| </li>`).join("\n") | ||
| : rows.map((item) => `<li class="resource-row"> | ||
| <div class="resource-meta"> | ||
| <strong>${escapeHtml(item.title || item.name || item.id)}</strong> | ||
| <span class="muted">${escapeHtml(item.description || item.message || "")}</span> | ||
| </div> | ||
| <span class="badge">${escapeHtml(item.status || "sample")}</span> | ||
| </li>`).join("\n"); | ||
| return `<article class="panel" data-topogram-widget="${escapeAttr(widgetId)}"> | ||
| <h2>${escapeHtml(title)}</h2> | ||
| <ul class="resource-list"> | ||
| ${fieldMarkup} | ||
| </ul> | ||
| </article>`; | ||
| } | ||
| function renderSection(section) { | ||
| return `<article class="panel" data-topogram-section="${escapeAttr(section.id)}"> | ||
| <h2>${escapeHtml(labelFromId(section.id) || section.id)}</h2> | ||
| <p class="muted">${escapeHtml(section.description || "Modeled screen content.")}</p> | ||
| </article>`; | ||
| } | ||
| function renderDefaultCollection(sampleItems) { | ||
| return `<section class="panel"> | ||
| <h2>Sample rows</h2> | ||
| <ul class="resource-list"> | ||
| ${sampleItems.slice(0, 4).map((item) => ` <li class="resource-row"> | ||
| <div class="resource-meta"> | ||
| <strong>${escapeHtml(item.title || item.name || item.id)}</strong> | ||
| <span class="muted">${escapeHtml(item.description || item.message || "")}</span> | ||
| </div> | ||
| <span class="badge">${escapeHtml(item.status || "sample")}</span> | ||
| </li>`).join("\n")} | ||
| </ul> | ||
| </section>`; | ||
| } | ||
| function renderRegion(screen, region, sampleItems) { | ||
| const widgets = (screen.widgets || []) | ||
| .filter((usage) => usage.region === region || usage.regionContractId === region) | ||
| .map((usage) => renderWidgetUsage(screen, usage, sampleItems)) | ||
| .filter(Boolean); | ||
| const sections = (screen.sections || []) | ||
| .filter((section) => section.region === region) | ||
| .map(renderSection) | ||
| .filter(Boolean); | ||
| const actions = screenActionEntries(screen).filter((action) => | ||
| (screen.renders || []).some((render) => | ||
| render.region === region && | ||
| render.unitKind === "action" && | ||
| (render.target?.id === actionId(action) || render.action?.id === actionId(action)) | ||
| ) | ||
| ); | ||
| const actionMarkup = actions.length > 0 | ||
| ? `<article class="panel"> | ||
| ${actions.map((action) => ` <button type="button" data-topogram-action="${escapeAttr(actionId(action))}">${escapeHtml(actionName(action))}</button>`).join("\n")} | ||
| </article>` | ||
| : ""; | ||
| const rendered = [...widgets, ...sections, actionMarkup].filter(Boolean).join("\n"); | ||
| if (!rendered) return ""; | ||
| return `<section class="screen-region" data-topogram-region="${escapeAttr(region)}"> | ||
| ${rendered} | ||
| </section>`; | ||
| } | ||
| function renderVanillaScreenBody(screen, contract, index, total) { | ||
| const titleText = screenTitleText(screen); | ||
| const leadText = screenLeadText(screen, titleText); | ||
| const sampleItems = sampleItemsForScreen(screen); | ||
| const layoutRule = screen.journeyLayout?.layoutRule || "unmapped_screen"; | ||
| const attrs = [ | ||
| `data-topogram-screen="${escapeAttr(screen.id)}"`, | ||
| screen.navpointId ? `data-topogram-navpoint="${escapeAttr(screen.navpointId)}"` : null, | ||
| screen.layout?.id ? `data-topogram-layout="${escapeAttr(screen.layout.id)}"` : null, | ||
| `data-topogram-output-mode="${USABLE_APP_OUTPUT_MODE}"`, | ||
| `data-topogram-layout-rule="${escapeAttr(layoutRule)}"` | ||
| ].filter(Boolean).join(" "); | ||
| const regions = screenRegions(screen) | ||
| .map((region) => renderRegion(screen, region, sampleItems)) | ||
| .filter(Boolean) | ||
| .join("\n"); | ||
| return ` <article class="screen-shell" ${attrs}> | ||
| <section class="panel"> | ||
| <div class="screen-heading"> | ||
| <div> | ||
| <p class="muted">Page ${index + 1} of ${total} · ${escapeHtml(screen.kind || "screen")}</p> | ||
| <h1>${escapeHtml(titleText)}</h1> | ||
| <p data-topogram-copy="screen-intent">${escapeHtml(leadText)}</p> | ||
| </div> | ||
| ${renderScreenActions(screen)} | ||
| </div> | ||
| <p class="muted" data-topogram-interaction-status>Prototype actions are ready.</p> | ||
| </section> | ||
| ${renderEmptyState(screen, titleText)} | ||
| ${renderForm(screen, titleText)} | ||
| ${regions || renderDefaultCollection(sampleItems)} | ||
| </article>`; | ||
| } | ||
| function renderBuildScript() { | ||
@@ -165,2 +456,15 @@ return `import fs from "node:fs"; | ||
| } | ||
| for (const marker of [ | ||
| 'data-topogram-output-mode="usable_app"', | ||
| 'data-topogram-screen=', | ||
| 'data-topogram-copy="screen-intent"', | ||
| 'data-topogram-empty-state="screen"' | ||
| ]) { | ||
| if (!html.includes(marker)) { | ||
| throw new Error(\`\${file} is missing usable app marker \${marker}.\`); | ||
| } | ||
| } | ||
| if (/generated from <code>|Topogram UI contract metadata|generated row for widget rendering checks/i.test(html)) { | ||
| throw new Error(\`\${file} still contains contract-preview scaffold copy.\`); | ||
| } | ||
| } | ||
@@ -183,16 +487,44 @@ console.log(\`Checked \${htmlFiles.length} vanilla page(s).\`); | ||
| screen: route.screenId, | ||
| route: route.path, | ||
| message: `Screen '${route.screenId}' has route '${route.path}' but no vanilla HTML page was generated.`, | ||
| suggested_fix: "Check the vanilla web generator route emission for this screen." | ||
| navpoint: route.path, | ||
| message: `Screen '${route.screenId}' has navpoint '${route.path}' but no vanilla HTML page was generated.`, | ||
| suggested_fix: "Check the vanilla web generator navpoint emission for this screen." | ||
| }); | ||
| } | ||
| const widgetUsages = (route.screen?.widgets || []).map((usage) => { | ||
| const widgetId = usage.widget?.id; | ||
| const marker = widgetId ? `data-topogram-widget="${widgetId}"` : ""; | ||
| const renderedWidget = Boolean(widgetId && contents.includes(marker)); | ||
| return { | ||
| widget: widgetId, | ||
| region: usage.region || null, | ||
| pattern: usage.pattern || null, | ||
| pattern_category: "content", | ||
| supported: true, | ||
| status: renderedWidget ? "rendered" : "missing", | ||
| rendered: renderedWidget, | ||
| display_fields: (usage.displayFields || []).map((field) => field.name).filter(Boolean), | ||
| display_fields_rendered: (usage.displayFields || []).every((field) => | ||
| field?.name && contents.includes(`data-topogram-display-field="${field.name}"`) | ||
| ) | ||
| }; | ||
| }); | ||
| const usableOutput = buildUsableScreenCoverage({ | ||
| screen: route.screen || { id: route.screenId, navpoint: route.path, route: route.path }, | ||
| contents, | ||
| rendered, | ||
| widgetUsages, | ||
| diagnostics, | ||
| generator: "Vanilla web" | ||
| }); | ||
| return { | ||
| id: route.screenId, | ||
| route: route.path, | ||
| navpoint: route.path, | ||
| page: route.file, | ||
| rendered, | ||
| renderer: rendered ? "generator" : "missing", | ||
| widget_usages: [] | ||
| widget_usages: widgetUsages, | ||
| usable_output: usableOutput | ||
| }; | ||
| }); | ||
| const usableSummary = usableOutputSummary(screens); | ||
| return { | ||
@@ -208,8 +540,11 @@ type: "generation_coverage", | ||
| summary: { | ||
| routed_screens: screens.length, | ||
| navpoint_screens: screens.length, | ||
| rendered_screens: screens.filter((screen) => screen.rendered).length, | ||
| implementation_screens: 0, | ||
| generator_screens: screens.filter((screen) => screen.renderer === "generator").length, | ||
| widget_usages: 0, | ||
| rendered_widget_usages: 0, | ||
| widget_usages: screens.reduce((sum, screen) => sum + (screen.widget_usages || []).length, 0), | ||
| rendered_widget_usages: screens.reduce((sum, screen) => sum + (screen.widget_usages || []).filter((usage) => usage.rendered).length, 0), | ||
| usable_output_screens: usableSummary.usable_output_screens, | ||
| usable_output_complete_screens: usableSummary.usable_output_complete_screens, | ||
| preview_scaffold_screens: usableSummary.preview_scaffold_screens, | ||
| diagnostics: diagnostics.length, | ||
@@ -219,2 +554,6 @@ errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length, | ||
| }, | ||
| output_mode: { | ||
| claimed: USABLE_APP_OUTPUT_MODE, | ||
| ...usableSummary | ||
| }, | ||
| design_intent: designIntent.coverage, | ||
@@ -241,4 +580,5 @@ screens, | ||
| const root = path.resolve(fileURLToPath(new URL("..", import.meta.url))); | ||
| const root = fs.realpathSync(path.resolve(fileURLToPath(new URL("..", import.meta.url)))); | ||
| const port = Number(process.env.PORT || process.env.WEB_PORT || 5173); | ||
| const host = process.env.WEB_HOST || process.env.HOST || "127.0.0.1"; | ||
| const types = new Map([ | ||
@@ -250,18 +590,42 @@ [".html", "text/html; charset=utf-8"], | ||
| function containedByRoot(filePath) { | ||
| const relative = path.relative(root, filePath); | ||
| return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); | ||
| } | ||
| function resolveRequest(pathname) { | ||
| let requested; | ||
| try { | ||
| requested = decodeURIComponent(pathname === "/" ? "/index.html" : pathname); | ||
| } catch { | ||
| return { status: 400, message: "Bad request" }; | ||
| } | ||
| if (requested.includes("\\0")) { | ||
| return { status: 400, message: "Bad request" }; | ||
| } | ||
| const candidate = path.resolve(root, "." + requested); | ||
| if (!containedByRoot(candidate)) { | ||
| return { status: 403, message: "Forbidden" }; | ||
| } | ||
| if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { | ||
| return { status: 404, message: "Not found" }; | ||
| } | ||
| const realPath = fs.realpathSync(candidate); | ||
| if (!containedByRoot(realPath)) { | ||
| return { status: 403, message: "Forbidden" }; | ||
| } | ||
| return { status: 200, filePath: realPath }; | ||
| } | ||
| http.createServer((req, res) => { | ||
| const url = new URL(req.url || "/", \`http://localhost:\${port}\`); | ||
| const requested = url.pathname === "/" ? "/index.html" : url.pathname; | ||
| const filePath = path.normalize(path.join(root, requested)); | ||
| if (!filePath.startsWith(root)) { | ||
| res.writeHead(403).end("Forbidden"); | ||
| const resolved = resolveRequest(url.pathname); | ||
| if (resolved.status !== 200) { | ||
| res.writeHead(resolved.status).end(resolved.message); | ||
| return; | ||
| } | ||
| if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { | ||
| res.writeHead(404).end("Not found"); | ||
| return; | ||
| } | ||
| res.writeHead(200, { "content-type": types.get(path.extname(filePath)) || "application/octet-stream" }); | ||
| fs.createReadStream(filePath).pipe(res); | ||
| }).listen(port, () => { | ||
| console.log(\`Vanilla web app listening on http://localhost:\${port}\`); | ||
| res.writeHead(200, { "content-type": types.get(path.extname(resolved.filePath)) || "application/octet-stream" }); | ||
| fs.createReadStream(resolved.filePath).pipe(res); | ||
| }).listen(port, host, () => { | ||
| console.log(\`Vanilla web app listening on http://\${host}:\${port}\`); | ||
| }); | ||
@@ -275,6 +639,9 @@ `; | ||
| const routeScreens = (contract.screens || []).filter((screen) => Boolean(screen.route)); | ||
| const routes = (routeScreens.length > 0 ? routeScreens : [{ id: "home", route: "/", title: "Home" }]).map((screen) => ({ | ||
| const routes = (routeScreens.length > 0 ? routeScreens : [{ id: "home", route: "/", title: "Home", widgets: [], sections: [], renders: [] }]).map((screen) => ({ | ||
| screen, | ||
| screenId: screen.id, | ||
| navpointId: screen.navpointId || screen.routeId || null, | ||
| routeId: screen.navpointId || screen.routeId || null, | ||
| path: screen.route || "/", | ||
| title: screen.title || titleForScreen(screen.id), | ||
| title: screenTitleText(screen), | ||
| file: routeFileName(screen.route || "/") | ||
@@ -303,13 +670,6 @@ })); | ||
| routes.forEach((route, index) => { | ||
| const safeTitle = escapeHtml(route.title); | ||
| const safeProjectionId = escapeHtml(contract.surface.id); | ||
| files[route.file] = renderHtml({ | ||
| title: route.title, | ||
| nav, | ||
| body: ` <section class="panel"> | ||
| <p class="muted">Page ${index + 1} of ${routes.length}</p> | ||
| <h1>${safeTitle}</h1> | ||
| <p>This page was generated from the <code>${safeProjectionId}</code> Topogram web surface.</p> | ||
| <p class="muted">Generated timestamp: <span data-generated-at>pending</span></p> | ||
| </section>` | ||
| body: renderVanillaScreenBody(route.screen, contract, index, routes.length) | ||
| }); | ||
@@ -316,0 +676,0 @@ }); |
@@ -54,3 +54,3 @@ // @ts-check | ||
| title: screen.title || screen.id, | ||
| route: screen.route || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| layout: screen.layout?.id || null, | ||
@@ -121,2 +121,1 @@ widget_bindings: (screen.widget_bindings || []).map(/** @param {AnyRecord} binding */ (binding) => binding.id) | ||
| } | ||
@@ -15,3 +15,3 @@ /** | ||
| `Screens: ${report.designer_review?.summary?.screens || 0}`, | ||
| `Widget bindings: ${report.designer_review?.summary?.widget_bindings || 0}`, | ||
| `Screen renders: ${report.designer_review?.summary?.widget_bindings || 0}`, | ||
| `Component mappings: ${report.designer_review?.summary?.component_mappings || 0}`, | ||
@@ -36,3 +36,3 @@ `Review rows: ${report.designer_review?.summary?.review_rows || 0}`, | ||
| `Surfaces: ${report.summary?.surfaces || 0}`, | ||
| `Routes: ${report.summary?.routes || 0}`, | ||
| `Navpoints: ${report.summary?.navpoints ?? report.summary?.routes ?? 0}`, | ||
| `Screens: ${report.summary?.screens || 0}`, | ||
@@ -42,3 +42,3 @@ `Layouts: ${report.summary?.layouts || 0}`, | ||
| `Widgets: ${report.summary?.widgets || 0}`, | ||
| `Widget bindings: ${report.summary?.widget_bindings || 0}`, | ||
| `Screen renders: ${report.summary?.widget_bindings || 0}`, | ||
| `Component maps: ${report.summary?.component_maps || 0}`, | ||
@@ -57,10 +57,10 @@ `Gaps: ${report.summary?.gaps || 0}`, | ||
| lines.push("## Route Inventory", "", "| Route | Surface | Path | Screen | Source |", "| --- | --- | --- | --- | --- |"); | ||
| for (const route of report.routes || []) { | ||
| lines.push("## Navpoint Inventory", "", "| Navpoint | Surface | Path | Screen | Source |", "| --- | --- | --- | --- | --- |"); | ||
| for (const navpoint of report.navpoints || report.routes || []) { | ||
| lines.push(tableRow([ | ||
| route.id ? code(route.id) : "_legacy_", | ||
| code(route.surface), | ||
| route.path || "", | ||
| code(route.screen), | ||
| `${route.source || "routes"}${route.derived ? " (derived)" : ""}` | ||
| navpoint.id ? code(navpoint.id) : "_legacy_", | ||
| code(navpoint.surface), | ||
| navpoint.path || "", | ||
| code(navpoint.screen), | ||
| `${navpoint.source || "navpoints"}${navpoint.derived ? " (derived)" : ""}` | ||
| ])); | ||
@@ -70,3 +70,3 @@ } | ||
| lines.push("## Screen Inventory", "", "| Screen | Surface | Route | Layout | Regions | Widget Bindings |", "| --- | --- | --- | --- | --- | --- |"); | ||
| lines.push("## Screen Inventory", "", "| Screen | Surface | Navpoint | Layout | Regions | Renders |", "| --- | --- | --- | --- | --- | --- |"); | ||
| for (const screen of report.screens || []) { | ||
@@ -76,3 +76,3 @@ lines.push(tableRow([ | ||
| code(screen.surface), | ||
| screen.route || "", | ||
| screen.navpoint || screen.route || "", | ||
| screen.layout?.id ? `${code(screen.layout.id)}${screen.layout.pattern ? ` (${screen.layout.pattern})` : ""}` : "", | ||
@@ -94,3 +94,3 @@ (screen.regions || []).map((region) => `${region.id || region.region_contract}${region.slot_role ? `/${region.slot_role}` : ""}`).join(", "), | ||
| lines.push("", "### Regions", "", "| Region | Pattern | Slot Roles | Style Intent | Widget Bindings |", "| --- | --- | --- | --- | --- |"); | ||
| lines.push("", "### Regions", "", "| Region | Pattern | Slot Roles | Style Intent | Renders |", "| --- | --- | --- | --- | --- |"); | ||
| for (const region of report.regions || []) { | ||
@@ -106,3 +106,3 @@ lines.push(tableRow([ | ||
| lines.push("", "## Widget Binding Work Leaves", "", "| Binding | Screen | Region | Widget | Data Sources | Actions | Style Intent |", "| --- | --- | --- | --- | --- | --- | --- |"); | ||
| lines.push("", "## Render Work Leaves", "", "| Render | Screen | Region | Widget | Data Sources | Actions | Style Intent |", "| --- | --- | --- | --- | --- | --- | --- |"); | ||
| for (const screen of report.screens || []) { | ||
@@ -109,0 +109,0 @@ for (const binding of screen.widget_bindings || []) { |
| export function componentMapReports(coverage: Record<string, any>, includedComponentMapIds: Set<string>): Record<string, any>[]; | ||
| export function gapReports(coverage: Record<string, any>, context: Record<string, any>): Record<string, any>[]; | ||
| export function layoutReports(includedScreens: Array<{ contract: Record<string, any>; screen: Record<string, any> }>): Record<string, any>[]; | ||
| export function navpointReports(contracts: Record<string, any>[], includedScreens: Array<{ contract: Record<string, any>; screen: Record<string, any> }>): Record<string, any>[]; | ||
| export function regionReports(includedScreens: Array<{ contract: Record<string, any>; screen: Record<string, any> }>): Record<string, any>[]; | ||
@@ -5,0 +6,0 @@ export function routeReports(contracts: Record<string, any>[], includedScreens: Array<{ contract: Record<string, any>; screen: Record<string, any> }>): Record<string, any>[]; |
@@ -14,2 +14,3 @@ // @ts-check | ||
| const screens = includedScreens.filter((entry) => entry.contract.surface?.id === contract.surface?.id); | ||
| const navpoints = contract.navpoints || contract.routes || []; | ||
| return { | ||
@@ -21,7 +22,7 @@ id: contract.surface?.id || null, | ||
| screen_count: screens.length, | ||
| routes: (contract.routes || []).map(/** @param {AnyRecord} route */ (route) => ({ | ||
| id: route.id || null, | ||
| path: route.path || null, | ||
| screen: route.screenId || null, | ||
| derived: Boolean(route.derived) | ||
| navpoints: navpoints.map(/** @param {AnyRecord} navpoint */ (navpoint) => ({ | ||
| id: navpoint.id || null, | ||
| path: navpoint.path || null, | ||
| screen: navpoint.screenId || null, | ||
| derived: Boolean(navpoint.derived) | ||
| })), | ||
@@ -47,4 +48,4 @@ widget_binding_count: screens.reduce((sum, entry) => sum + (entry.screen.widgets || []).length, 0), | ||
| title: screen.title || screen.id, | ||
| route_id: screen.routeId || null, | ||
| route: screen.route || null, | ||
| navpoint_id: screen.navpointId || screen.routeId || null, | ||
| navpoint: screen.navpoint || screen.route || null, | ||
| kind: screen.kind || null, | ||
@@ -93,19 +94,21 @@ surface: contract.surface?.id || null, | ||
| */ | ||
| function routeReports(contracts, includedScreens) { | ||
| function navpointReports(contracts, includedScreens) { | ||
| const includedSurfaces = new Set(includedScreens.map((entry) => entry.contract.surface?.id).filter(Boolean)); | ||
| return contracts | ||
| .filter((contract) => includedSurfaces.has(contract.surface?.id)) | ||
| .flatMap((contract) => (contract.routes || []).map(/** @param {AnyRecord} route */ (route) => ({ | ||
| id: route.id || null, | ||
| .flatMap((contract) => (contract.navpoints || contract.routes || []).map(/** @param {AnyRecord} navpoint */ (navpoint) => ({ | ||
| id: navpoint.id || null, | ||
| surface: contract.surface?.id || null, | ||
| path: route.path || null, | ||
| screen: route.screenId || null, | ||
| loader: route.loader?.id || null, | ||
| action: route.action?.id || null, | ||
| auth: route.auth || null, | ||
| derived: Boolean(route.derived), | ||
| source: route.source || null | ||
| path: navpoint.path || null, | ||
| screen: navpoint.screenId || null, | ||
| loader: navpoint.loader?.id || null, | ||
| action: navpoint.action?.id || null, | ||
| auth: navpoint.auth || null, | ||
| derived: Boolean(navpoint.derived), | ||
| source: navpoint.source || null | ||
| }))); | ||
| } | ||
| const routeReports = navpointReports; | ||
| /** | ||
@@ -205,3 +208,3 @@ * @param {AnyRecord} screen | ||
| }; | ||
| entry.screens.push({ id: screen.id, surface: contract.surface?.id || null, route: screen.route || null }); | ||
| entry.screens.push({ id: screen.id, surface: contract.surface?.id || null, navpoint: screen.navpoint || screen.route || null }); | ||
| for (const region of screen.regions || []) { | ||
@@ -563,2 +566,3 @@ if (region.layoutId !== layout.id) continue; | ||
| layoutReports, | ||
| navpointReports, | ||
| regionReports, | ||
@@ -565,0 +569,0 @@ routeReports, |
@@ -6,3 +6,3 @@ // @ts-check | ||
| import { designerReviewReport } from "./work-map-designer-review.js"; | ||
| import { componentMapReports, gapReports, layoutReports, regionReports, routeReports, screenReport, selectedComponentMaps, surfaceReport, widgetReports } from "./work-map-report-sections.js"; | ||
| import { componentMapReports, gapReports, layoutReports, navpointReports, regionReports, screenReport, selectedComponentMaps, surfaceReport, widgetReports } from "./work-map-report-sections.js"; | ||
@@ -50,3 +50,3 @@ /** | ||
| .map((contract) => surfaceReport(contract, includedScreens)); | ||
| const routes = routeReports(contracts, includedScreens); | ||
| const navpoints = navpointReports(contracts, includedScreens); | ||
| const commands = proofCommands(selectors); | ||
@@ -77,3 +77,3 @@ const designerReview = designerReviewReport({ | ||
| surfaces: surfaces.length, | ||
| routes: routes.length, | ||
| navpoints: navpoints.length, | ||
| screens: screens.length, | ||
@@ -90,3 +90,3 @@ layouts: layouts.length, | ||
| surfaces, | ||
| routes, | ||
| navpoints, | ||
| screens, | ||
@@ -93,0 +93,0 @@ layouts, |
@@ -100,3 +100,3 @@ // @ts-check | ||
| behavior: row.behavior.kind, | ||
| suggested_fix: "Bind the required behavior data, events, or capability actions in the projection widget_bindings entry." | ||
| suggested_fix: "Bind the required behavior data, events, or capability actions in the screen.renders entry." | ||
| }); | ||
@@ -116,3 +116,3 @@ } | ||
| behavior: row.behavior.kind, | ||
| suggested_fix: `Add 'event ${emittedEvent.event} navigate <screen>' or 'event ${emittedEvent.event} action <capability>' to the projection widget_bindings entry.` | ||
| suggested_fix: `Add 'event ${emittedEvent.event} navigate <screen>' or 'event ${emittedEvent.event} action <capability>' to the screen.renders entry.` | ||
| }); | ||
@@ -136,4 +136,4 @@ } | ||
| suggested_fix: action.capability?.id | ||
| ? `Add 'event <widget_event> action ${action.capability.id}' to the projection widget_bindings entry.` | ||
| : `Add 'event ${action.event} action <capability>' or 'event ${action.event} navigate <screen>' to the projection widget_bindings entry.` | ||
| ? `Add 'event <widget_event> action ${action.capability.id}' to the screen.renders entry.` | ||
| : `Add 'event ${action.event} action <capability>' or 'event ${action.event} navigate <screen>' to the screen.renders entry.` | ||
| }); | ||
@@ -140,0 +140,0 @@ } |
@@ -90,3 +90,3 @@ // @ts-check | ||
| usage, | ||
| suggestedFix: "Create the widget or update the projection widget_bindings binding." | ||
| suggestedFix: "Create the widget or update the screen.renders entry." | ||
| })); | ||
@@ -146,3 +146,3 @@ return checks; | ||
| prop: prop.name, | ||
| suggestedFix: `Add 'data ${prop.name} from <source>' to the projection widget_bindings entry.` | ||
| suggestedFix: `Add 'data ${prop.name} from <source>' to the screen.renders entry.` | ||
| })); | ||
@@ -297,3 +297,3 @@ } | ||
| behavior: behavior.kind, | ||
| suggestedFix: `Add 'event ${eventName} navigate <screen>' or 'event ${eventName} action <capability>' to the projection widget_bindings entry.` | ||
| suggestedFix: `Add 'event ${eventName} navigate <screen>' or 'event ${eventName} action <capability>' to the screen.renders entry.` | ||
| })); | ||
@@ -319,3 +319,3 @@ } | ||
| behavior: behavior.kind, | ||
| suggestedFix: `Add 'event ${actionTarget} action <capability>' or 'event ${actionTarget} navigate <screen>' to the projection widget_bindings entry.` | ||
| suggestedFix: `Add 'event ${actionTarget} action <capability>' or 'event ${actionTarget} navigate <screen>' to the screen.renders entry.` | ||
| })); | ||
@@ -368,3 +368,3 @@ } | ||
| behavior: behavior.kind, | ||
| suggestedFix: `Add 'event <widget_event> action ${actionTarget}' to the projection widget_bindings entry.` | ||
| suggestedFix: `Add 'event <widget_event> action ${actionTarget}' to the screen.renders entry.` | ||
| })); | ||
@@ -371,0 +371,0 @@ } |
+33
-2
@@ -18,6 +18,16 @@ // @ts-check | ||
| } | ||
| function canAttachToken(urlValue) { | ||
| function tokenHostAllowed(urlValue) { | ||
| const hostname = new URL(urlValue).hostname.toLowerCase(); | ||
| return hostname === "api.github.com" || hostname.endsWith(".github.com"); | ||
| } | ||
| function canAttachToken(urlValue) { | ||
| const parsed = new URL(urlValue); | ||
| return parsed.protocol === "https:" && tokenHostAllowed(urlValue); | ||
| } | ||
| function assertSafeTokenUrl(urlValue) { | ||
| const parsed = new URL(urlValue); | ||
| if (request.token && tokenHostAllowed(urlValue) && parsed.protocol !== "https:") { | ||
| throw new Error("Refusing to use GitHub token with non-HTTPS URL: " + urlValue); | ||
| } | ||
| } | ||
| const headers = { | ||
@@ -28,2 +38,8 @@ accept: "application/vnd.github+json", | ||
| }; | ||
| try { | ||
| assertSafeTokenUrl(url); | ||
| } catch (error) { | ||
| process.stderr.write(error instanceof Error ? error.message : String(error)); | ||
| process.exit(1); | ||
| } | ||
| if (request.token && canAttachToken(url)) { | ||
@@ -103,4 +119,19 @@ headers.authorization = "Bearer " + request.token; | ||
| } | ||
| async function fetchGitHub(urlValue, redirects = 0) { | ||
| if (redirects > 5) { | ||
| throw new Error("Too many GitHub REST redirects."); | ||
| } | ||
| assertSafeTokenUrl(urlValue); | ||
| const response = await fetch(urlValue, { headers, redirect: "manual" }); | ||
| if (response.status >= 300 && response.status < 400 && response.headers.get("location")) { | ||
| const next = new URL(response.headers.get("location"), urlValue); | ||
| if (headers.authorization && !canAttachToken(next)) { | ||
| throw new Error("Refusing GitHub token redirect to untrusted URL: " + next.toString()); | ||
| } | ||
| return fetchGitHub(next, redirects + 1); | ||
| } | ||
| return response; | ||
| } | ||
| try { | ||
| const response = await fetch(url, { headers }); | ||
| const response = await fetchGitHub(url); | ||
| const text = await readResponseText(response); | ||
@@ -107,0 +138,0 @@ if (!response.ok) { |
@@ -96,2 +96,3 @@ // @ts-check | ||
| doctor: "topogram doctor", | ||
| onboard: "topogram onboard", | ||
| "agent:brief": "topogram agent brief --json", | ||
@@ -129,2 +130,3 @@ "source:status": "topogram source status --local", | ||
| "app:runtime-check": "npm --prefix ./app run runtime-check", | ||
| "app:runtime-e2e": "npm --prefix ./app run runtime-e2e", | ||
| "app:check": "npm run app:compile", | ||
@@ -158,6 +160,7 @@ "app:probe": "npm run app:smoke && npm run app:runtime-check", | ||
| 2. Start with project guidance: | ||
| npm run agent:brief | ||
| 2. Start with the adoption loop: | ||
| npm run onboard | ||
| 3. Validate: | ||
| npm run agent:brief | ||
| npm run doctor | ||
@@ -184,3 +187,7 @@ npm run source:status | ||
| For generated full-stack runtime proof: | ||
| npm run app:runtime-e2e | ||
| Useful inspection: | ||
| npm run onboard | ||
| npm run agent:brief | ||
@@ -226,2 +233,3 @@ npm run check:json | ||
| "npm run explain", | ||
| "npm run onboard", | ||
| "npm run agent:brief", | ||
@@ -270,3 +278,3 @@ "npm run doctor", | ||
| Use \`topogram emit <target>\` to inspect contracts, reports, snapshots, and other artifacts without regenerating the app. | ||
| Agents should start with \`AGENTS.md\` and \`npm run agent:brief\`. The direct \`topogram agent brief --json\` command is the canonical machine-readable first-run guidance. | ||
| Agents should start with \`AGENTS.md\` and \`npm run onboard\`. The direct \`topogram agent brief --json\` command remains the canonical machine-readable guidance inside the onboard loop. | ||
| ${template.includesExecutableImplementation ? "\nThis template copied `implementation/` code. `topogram copy` did not execute it; review `implementation/`, `topogram.template-policy.json`, and `.topogram-template-trust.json` before regenerating after edits.\n" : ""} | ||
@@ -301,2 +309,3 @@ `; | ||
| \`\`\`bash | ||
| npm run onboard | ||
| topogram agent brief --json | ||
@@ -308,2 +317,3 @@ \`\`\` | ||
| \`\`\`bash | ||
| npm run onboard | ||
| npm run agent:brief | ||
@@ -317,2 +327,3 @@ \`\`\` | ||
| \`\`\`bash | ||
| npm run onboard | ||
| npm run agent:brief | ||
@@ -346,5 +357,6 @@ npm run doctor | ||
| - \`semantic_ui\` surfaces own screens, layout usage, screen region overrides, widget bindings, behavior, visibility, and semantic design tokens. | ||
| - Prefer reusable \`region\` and \`layout\` records for shared UI structure. \`screen_regions\` are for one-off regions or overrides. | ||
| - Web/iOS/Android surfaces realize the shared UI contract; they do not own widget placement. | ||
| - \`route\` records answer how users get to screens; concrete web/iOS/Android surfaces choose which routes they expose. | ||
| - \`screen\` records own layout usage and \`renders\` entries for widgets, actions, or named sections. | ||
| - Prefer reusable \`region\` and \`layout\` records for shared UI structure. \`section\` records are for named, checkable UI areas that are not reusable widgets yet. | ||
| - \`semantic_ui\` surfaces own screens, behavior, visibility, and semantic design tokens; concrete surfaces realize routes and stack hints. | ||
| - Use \`topogram widget check --json\`, \`topogram widget behavior --json\`, and focused \`topogram query ...\` packets after UI edits. | ||
@@ -366,2 +378,3 @@ | ||
| \`\`\`bash | ||
| npm run onboard | ||
| npm run check | ||
@@ -368,0 +381,0 @@ npm run generate |
@@ -18,3 +18,3 @@ import { stableStringify } from "../format.js"; | ||
| id: screen.id ?? null, | ||
| route: screen.route ?? null, | ||
| navpoint: screen.navpoint ?? screen.route ?? null, | ||
| kind: screen.kind ?? null, | ||
@@ -35,3 +35,3 @@ title: screen.title ?? null, | ||
| screenId: item.screenId ?? null, | ||
| route: item.route ?? null, | ||
| navpoint: item.navpoint ?? item.route ?? null, | ||
| label: item.label ?? null, | ||
@@ -38,0 +38,0 @@ placement: item.placement ?? null, |
@@ -19,3 +19,3 @@ import { stableStringify } from "../format.js"; | ||
| id: screen.id, | ||
| route: screen.route ?? null, | ||
| navpoint: screen.navpoint ?? screen.route ?? null, | ||
| kind: screen.kind ?? null, | ||
@@ -22,0 +22,0 @@ title: screen.title ?? null, |
+49
-3
@@ -47,3 +47,44 @@ // @ts-check | ||
| const ROUTE_OBJECT_HINT_KEYS = new Set([ | ||
| "action", | ||
| "auth", | ||
| "derived", | ||
| "loader", | ||
| "method", | ||
| "response_container", | ||
| "screen", | ||
| "screenId", | ||
| "source", | ||
| "success" | ||
| ]); | ||
| /** | ||
| * @param {string} value | ||
| * @returns {boolean} | ||
| */ | ||
| function looksLikeUrlRoutePath(value) { | ||
| if (typeof value !== "string") return false; | ||
| if (!value.startsWith("/")) return false; | ||
| if (value.includes("\\") || /[\u0000-\u001F]/.test(value)) return false; | ||
| if (/^\/(?:Users|home|private|tmp|var|Volumes|Applications|Library|System|bin|dev|etc|opt|sbin|usr)(?:\/|$)/.test(value)) { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * @param {any} value | ||
| * @returns {boolean} | ||
| */ | ||
| function isRouteLikeObject(value) { | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) return false; | ||
| if (!looksLikeUrlRoutePath(value.path)) return false; | ||
| if (typeof value.id === "string" && value.id.startsWith("route_")) return true; | ||
| for (const key of ROUTE_OBJECT_HINT_KEYS) { | ||
| if (Object.hasOwn(value, key)) return true; | ||
| } | ||
| return false; | ||
| } | ||
| /** | ||
| * @param {string|null|undefined} value | ||
@@ -193,6 +234,10 @@ * @returns {string|null} | ||
| * @param {string|null} [key] | ||
| * @param {{ routeLike?: boolean }} [scope] | ||
| * @returns {any} | ||
| */ | ||
| export function sanitizePublicPayload(value, context = {}, key = null) { | ||
| export function sanitizePublicPayload(value, context = {}, key = null, scope = {}) { | ||
| if (typeof value === "string") { | ||
| if (key === "path" && scope.routeLike && looksLikeUrlRoutePath(value)) { | ||
| return replaceKnownPathSubstrings(value, context); | ||
| } | ||
| if (key && SOURCE_PATH_KEYS.has(key)) { | ||
@@ -207,9 +252,10 @@ return toPortableSourcePath(value, context); | ||
| if (Array.isArray(value)) { | ||
| return value.map((item) => sanitizePublicPayload(item, context, key)); | ||
| return value.map((item) => sanitizePublicPayload(item, context, key, scope)); | ||
| } | ||
| if (value && typeof value === "object") { | ||
| const childScope = isRouteLikeObject(value) ? { ...scope, routeLike: true } : scope; | ||
| return Object.fromEntries( | ||
| Object.entries(value).map(([entryKey, entryValue]) => [ | ||
| entryKey, | ||
| sanitizePublicPayload(entryValue, context, entryKey) | ||
| sanitizePublicPayload(entryValue, context, entryKey, childScope) | ||
| ]) | ||
@@ -216,0 +262,0 @@ ); |
@@ -421,6 +421,7 @@ import { getProjection, uiProjectionCandidates } from "../../generator/surfaces/shared.js"; | ||
| function buildNavigationContract(projection, screens) { | ||
| const routeMap = new Map((projection.uiRoutes || []).map((entry) => [entry.screenId, entry])); | ||
| const routeById = new Map((projection.uiRoutes || []).filter((entry) => entry.routeId).map((entry) => [entry.routeId, entry])); | ||
| const navpoints = projection.uiNavpoints || projection.uiRoutes || []; | ||
| const routeMap = new Map(navpoints.map((entry) => [entry.screenId, entry])); | ||
| const navpointById = new Map(navpoints.filter((entry) => entry.navpointId || entry.routeId).map((entry) => [entry.navpointId || entry.routeId, entry])); | ||
| const screenEntries = (projection.uiNavigation || []).filter((entry) => entry.targetKind === "screen"); | ||
| const routeEntries = (projection.uiNavigation || []).filter((entry) => entry.targetKind === "route"); | ||
| const navpointEntries = (projection.uiNavigation || []).filter((entry) => entry.targetKind === "navpoint"); | ||
| const groupEntries = (projection.uiNavigation || []).filter((entry) => entry.targetKind === "group"); | ||
@@ -436,14 +437,17 @@ const groups = groupEntries.map((entry) => ({ | ||
| const screenById = new Map(screens.map((screen) => [screen.id, screen])); | ||
| if (routeEntries.length > 0 || routeById.size > 0) { | ||
| const byRoute = new Map(routeEntries.map((entry) => [entry.targetId, entry])); | ||
| const items = [...routeById.values()].map((route) => { | ||
| const screen = screenById.get(route.screenId) || {}; | ||
| const entry = byRoute.get(route.routeId); | ||
| if (navpointEntries.length > 0 || navpointById.size > 0) { | ||
| const byNavpoint = new Map(navpointEntries.map((entry) => [entry.targetId, entry])); | ||
| const items = [...navpointById.values()].map((navpoint) => { | ||
| const screen = screenById.get(navpoint.screenId) || {}; | ||
| const navpointId = navpoint.navpointId || navpoint.routeId; | ||
| const entry = byNavpoint.get(navpointId); | ||
| const directives = entry?.directives || {}; | ||
| const derivedVisible = route.path ? !route.path.includes(":") && !["detail"].includes(screen.kind) : false; | ||
| const derivedVisible = navpoint.path ? !navpoint.path.includes(":") && !["detail"].includes(screen.kind) : false; | ||
| return { | ||
| routeId: route.routeId, | ||
| screenId: route.screenId, | ||
| route: route.path || null, | ||
| label: directives.label || screen.title || route.routeId, | ||
| navpointId, | ||
| routeId: navpointId, | ||
| screenId: navpoint.screenId, | ||
| navpoint: navpoint.path || null, | ||
| route: navpoint.path || null, | ||
| label: directives.label || screen.title || navpointId, | ||
| groupId: directives.group || null, | ||
@@ -469,3 +473,4 @@ placement: directives.placement || "primary", | ||
| defaultScreenId: items.find((item) => item.default)?.screenId || items.find((item) => item.visible)?.screenId || null, | ||
| defaultRouteId: items.find((item) => item.default)?.routeId || items.find((item) => item.visible)?.routeId || null | ||
| defaultNavpointId: items.find((item) => item.default)?.navpointId || items.find((item) => item.visible)?.navpointId || null, | ||
| defaultRouteId: items.find((item) => item.default)?.navpointId || items.find((item) => item.visible)?.navpointId || null | ||
| }; | ||
@@ -482,2 +487,4 @@ } | ||
| screenId: screen.id, | ||
| navpointId: routeMap.get(screen.id)?.navpointId || routeMap.get(screen.id)?.routeId || null, | ||
| navpoint: route, | ||
| route, | ||
@@ -506,2 +513,3 @@ label: directives.label || screen.title || screen.id, | ||
| defaultScreenId: items.find((item) => item.default)?.screenId || items.find((item) => item.visible)?.screenId || null, | ||
| defaultNavpointId: null, | ||
| defaultRouteId: null | ||
@@ -508,0 +516,0 @@ }; |
@@ -244,4 +244,5 @@ import { buildApiRealization } from "../api/index.js"; | ||
| const routeRealizations = projection.uiRoutes || []; | ||
| const routeMap = new Map(routeRealizations.map((entry) => [entry.screenId, entry])); | ||
| const navpointRealizations = projection.uiNavpoints || projection.uiRoutes || []; | ||
| const routeRealizations = navpointRealizations; | ||
| const routeMap = new Map(navpointRealizations.map((entry) => [entry.screenId, entry])); | ||
| const uiWebByScreen = new Map(); | ||
@@ -317,11 +318,27 @@ const uiWebByAction = new Map(); | ||
| defaultScreenId: navigation?.defaultScreenId || null, | ||
| defaultNavpointId: navigation?.defaultNavpointId || navigation?.defaultRouteId || null, | ||
| defaultRouteId: navigation?.defaultRouteId || null, | ||
| items: (navigation?.items || []).map((item) => ({ | ||
| ...item, | ||
| routeId: item.routeId || routeMap.get(item.screenId)?.routeId || null, | ||
| navpointId: item.navpointId || item.routeId || routeMap.get(item.screenId)?.navpointId || routeMap.get(item.screenId)?.routeId || null, | ||
| routeId: item.navpointId || item.routeId || routeMap.get(item.screenId)?.navpointId || routeMap.get(item.screenId)?.routeId || null, | ||
| navpoint: routeMap.get(item.screenId)?.path || item.navpoint || item.route || null, | ||
| route: routeMap.get(item.screenId)?.path || item.route || null | ||
| })) | ||
| }, | ||
| navpoints: navpointRealizations.map((entry) => ({ | ||
| id: entry.navpointId || entry.routeId || null, | ||
| navpointId: entry.navpointId || entry.routeId || null, | ||
| screenId: entry.screenId || null, | ||
| path: entry.path || null, | ||
| params: entry.params || [], | ||
| loader: entry.loader || null, | ||
| action: entry.action || null, | ||
| auth: entry.auth || null, | ||
| source: entry.source || null, | ||
| derived: Boolean(entry.derived) | ||
| })), | ||
| routes: routeRealizations.map((entry) => ({ | ||
| id: entry.routeId || null, | ||
| id: entry.navpointId || entry.routeId || null, | ||
| routeId: entry.navpointId || entry.routeId || null, | ||
| screenId: entry.screenId || null, | ||
@@ -338,3 +355,5 @@ path: entry.path || null, | ||
| ...screen, | ||
| routeId: routeMap.get(screen.id)?.routeId || null, | ||
| navpointId: routeMap.get(screen.id)?.navpointId || routeMap.get(screen.id)?.routeId || null, | ||
| routeId: routeMap.get(screen.id)?.navpointId || routeMap.get(screen.id)?.routeId || null, | ||
| navpoint: routeMap.get(screen.id)?.path || null, | ||
| route: routeMap.get(screen.id)?.path || null, | ||
@@ -355,4 +374,6 @@ surfaceHints: Object.fromEntries((uiWebByScreen.get(screen.id) || []).map((entry) => [entry.directive, entry.value])), | ||
| screenId: item.screenId, | ||
| routeId: item.routeId || routeMap.get(item.screenId)?.routeId || null, | ||
| navpointId: item.navpointId || item.routeId || routeMap.get(item.screenId)?.navpointId || routeMap.get(item.screenId)?.routeId || null, | ||
| routeId: item.navpointId || item.routeId || routeMap.get(item.screenId)?.navpointId || routeMap.get(item.screenId)?.routeId || null, | ||
| label: item.label, | ||
| navpoint: routeMap.get(item.screenId)?.path || item.navpoint || item.route || null, | ||
| route: routeMap.get(item.screenId)?.path || item.route || null, | ||
@@ -359,0 +380,0 @@ include: item.sitemap !== "exclude" |
+12
-110
@@ -8,2 +8,3 @@ import { buildRegistry, validateWorkspace } from "../validator.js"; | ||
| import { enrichBug } from "./enrich/bug.js"; | ||
| import { buildDomainMembersById, emptyDomainMembers } from "./domains.js"; | ||
| import { normalizeStatement } from "./normalize.js"; | ||
@@ -98,86 +99,3 @@ import { groupBy } from "./shared.js"; | ||
| // Build domain.members back-links by reverse-indexing tagged statements. | ||
| // Members are grouped per kind so consumers can ask for `domain.members.capabilities` | ||
| // without re-walking the registry. Phase 2 extends with SDLC kinds; documents | ||
| // are folded in below from workspaceAst.docs[].metadata.domain. | ||
| const domainMembersById = new Map(); | ||
| for (const statement of resolvedStatements) { | ||
| if (statement.kind === "domain") { | ||
| domainMembersById.set(statement.id, { | ||
| terms: [], | ||
| capabilities: [], | ||
| seedData: [], | ||
| themes: [], | ||
| entities: [], | ||
| rules: [], | ||
| verifications: [], | ||
| sections: [], | ||
| routes: [], | ||
| regions: [], | ||
| layouts: [], | ||
| designLanguages: [], | ||
| componentMaps: [], | ||
| surfaces: [], | ||
| decisions: [], | ||
| journeys: [], | ||
| workflows: [], | ||
| pitches: [], | ||
| requirements: [], | ||
| tasks: [], | ||
| plans: [], | ||
| bugs: [], | ||
| documents: [] | ||
| }); | ||
| } | ||
| } | ||
| const memberKindToBucket = { | ||
| term: "terms", | ||
| capability: "capabilities", | ||
| seed_data: "seedData", | ||
| theme: "themes", | ||
| entity: "entities", | ||
| rule: "rules", | ||
| verification: "verifications", | ||
| section: "sections", | ||
| route: "routes", | ||
| screen: "screens", | ||
| region: "regions", | ||
| layout: "layouts", | ||
| design_language: "designLanguages", | ||
| component_map: "componentMaps", | ||
| surface: "surfaces", | ||
| decision: "decisions", | ||
| journey: "journeys", | ||
| workflow: "workflows", | ||
| pitch: "pitches", | ||
| requirement: "requirements", | ||
| task: "tasks", | ||
| plan: "plans", | ||
| bug: "bugs" | ||
| }; | ||
| for (const statement of resolvedStatements) { | ||
| const bucketKey = memberKindToBucket[statement.kind]; | ||
| if (!bucketKey || !statement.resolvedDomain) { | ||
| continue; | ||
| } | ||
| const members = domainMembersById.get(statement.resolvedDomain.id); | ||
| if (members) { | ||
| members[bucketKey].push(statement.id); | ||
| } | ||
| } | ||
| // Fold tagged documents into domain.members.documents. | ||
| for (const doc of workspaceAst.docs || []) { | ||
| if (doc.parseError) continue; | ||
| const domainId = doc.metadata?.domain; | ||
| if (!domainId) continue; | ||
| const members = domainMembersById.get(domainId); | ||
| if (members && doc.metadata.id) { | ||
| members.documents.push(doc.metadata.id); | ||
| } | ||
| } | ||
| for (const members of domainMembersById.values()) { | ||
| for (const bucket of Object.values(members)) { | ||
| bucket.sort(); | ||
| } | ||
| } | ||
| const domainMembersById = buildDomainMembersById(resolvedStatements, workspaceAst.docs || []); | ||
@@ -202,2 +120,3 @@ // Phase 2: build SDLC back-link indices in a single pass over the resolved | ||
| plansByTask: new Map(), | ||
| tasksByFeature: new Map(), | ||
| affectedByPitches: new Map(), | ||
@@ -248,2 +167,3 @@ affectedByRequirements: new Map(), | ||
| case "task": | ||
| pushIndex(sdlcIndex.tasksByFeature, statement.feature?.id, statement.id); | ||
| pushIndexFromList(sdlcIndex.affectedByTasks, statement.affects, statement.id); | ||
@@ -321,2 +241,7 @@ pushIndexFromList(sdlcIndex.tasksBySatisfiedRequirement, statement.satisfies, statement.id); | ||
| }; | ||
| case "feature": | ||
| return { | ||
| ...statement, | ||
| tasks: (sdlcIndex.tasksByFeature.get(statement.id) || []).slice().sort() | ||
| }; | ||
| case "term": | ||
@@ -330,27 +255,3 @@ return { | ||
| ...statement, | ||
| members: domainMembersById.get(statement.id) || { | ||
| terms: [], | ||
| capabilities: [], | ||
| seedData: [], | ||
| themes: [], | ||
| entities: [], | ||
| rules: [], | ||
| verifications: [], | ||
| sections: [], | ||
| routes: [], | ||
| screens: [], | ||
| regions: [], | ||
| layouts: [], | ||
| designLanguages: [], | ||
| componentMaps: [], | ||
| surfaces: [], | ||
| decisions: [], | ||
| journeys: [], | ||
| workflows: [], | ||
| pitches: [], | ||
| requirements: [], | ||
| tasks: [], | ||
| bugs: [], | ||
| documents: [] | ||
| } | ||
| members: domainMembersById.get(statement.id) || emptyDomainMembers() | ||
| }; | ||
@@ -407,3 +308,4 @@ case "pitch": | ||
| case "section": | ||
| case "route": | ||
| case "navpoint": | ||
| case "endpoint": | ||
| case "screen": | ||
@@ -410,0 +312,0 @@ case "layout": |
@@ -63,3 +63,3 @@ import { | ||
| parseProjectionUiNavigationBlock, | ||
| parseProjectionUiRoutesBlock, | ||
| parseProjectionUiNavpointsBlock, | ||
| parseProjectionUiScreenRegionsBlock, | ||
@@ -192,3 +192,3 @@ parseProjectionUiScreensBlock, | ||
| }; | ||
| case "route": | ||
| case "navpoint": | ||
| return { | ||
@@ -220,2 +220,28 @@ ...base, | ||
| }; | ||
| case "endpoint": | ||
| return { | ||
| ...base, | ||
| method: symbolValue(getFieldValue(statement, "method")), | ||
| path: tokenText(getFieldValue(statement, "path")), | ||
| params: symbolValues(getFieldValue(statement, "params")), | ||
| capability: symbolValue(getFieldValue(statement, "capability")) | ||
| ? { | ||
| id: symbolValue(getFieldValue(statement, "capability")), | ||
| kind: registry.get(symbolValue(getFieldValue(statement, "capability")))?.kind || null | ||
| } | ||
| : null, | ||
| success: symbolValue(getFieldValue(statement, "success")) ? Number.parseInt(symbolValue(getFieldValue(statement, "success")), 10) : null, | ||
| auth: symbolValue(getFieldValue(statement, "auth")), | ||
| request: symbolValue(getFieldValue(statement, "request")), | ||
| responseResult: symbolValue(getFieldValue(statement, "response_result")), | ||
| responseEntity: symbolValue(getFieldValue(statement, "response_entity")) | ||
| ? { | ||
| id: symbolValue(getFieldValue(statement, "response_entity")), | ||
| kind: registry.get(symbolValue(getFieldValue(statement, "response_entity")))?.kind || null | ||
| } | ||
| : null, | ||
| responseContainer: symbolValue(getFieldValue(statement, "response_container")), | ||
| relatedTerms: relatedTerms(statement, registry), | ||
| resolvedDomain: resolveDomainTag(statement, registry) | ||
| }; | ||
| case "screen": | ||
@@ -344,4 +370,6 @@ return { | ||
| fieldLookups: parseProjectionUiLookupsBlock(statement, registry), | ||
| uiRoutes: parseProjectionUiRoutesBlock(statement, registry), | ||
| screenRoutes: parseProjectionUiRoutesBlock(statement, registry), | ||
| uiNavpoints: parseProjectionUiNavpointsBlock(statement, registry), | ||
| navpoints: parseProjectionUiNavpointsBlock(statement, registry), | ||
| uiRoutes: parseProjectionUiNavpointsBlock(statement, registry), | ||
| screenRoutes: parseProjectionUiNavpointsBlock(statement, registry), | ||
| uiWeb: parseProjectionUiWebBlock(statement, registry), | ||
@@ -460,2 +488,14 @@ webHints: parseProjectionUiWebBlock(statement, registry), | ||
| }; | ||
| case "feature": | ||
| return { | ||
| ...base, | ||
| relatedTerms: relatedTerms(statement, registry), | ||
| intent: stringValue(getFieldValue(statement, "intent")), | ||
| entities: resolveReferenceList(registry, getFieldValue(statement, "entities")), | ||
| capabilities: resolveReferenceList(registry, getFieldValue(statement, "capabilities")), | ||
| endpoints: resolveReferenceList(registry, getFieldValue(statement, "endpoints")), | ||
| seedData: resolveReferenceList(registry, getFieldValue(statement, "seed_data")), | ||
| verificationRefs: resolveReferenceList(registry, getFieldValue(statement, "verification_refs")), | ||
| resolvedDomain: resolveDomainTag(statement, registry) | ||
| }; | ||
| case "pitch": | ||
@@ -516,2 +556,14 @@ return { | ||
| changeType: symbolValue(getFieldValue(statement, "change_type")), | ||
| feature: getFieldValue(statement, "feature") | ||
| ? { | ||
| id: symbolValue(getFieldValue(statement, "feature")), | ||
| target: toRef(resolveReference(registry, symbolValue(getFieldValue(statement, "feature")))) | ||
| } | ||
| : null, | ||
| phase: symbolValue(getFieldValue(statement, "phase")), | ||
| scope: symbolValue(getFieldValue(statement, "scope")), | ||
| intent: stringValue(getFieldValue(statement, "intent")), | ||
| success: stringValue(getFieldValue(statement, "success")), | ||
| nonGoals: normalizeDomainScopeList(statement, "non_goals"), | ||
| entrypoints: normalizeDomainScopeList(statement, "entrypoints"), | ||
| affects: resolveReferenceList(registry, getFieldValue(statement, "affects")), | ||
@@ -518,0 +570,0 @@ satisfies: resolveReferenceList(registry, getFieldValue(statement, "satisfies")), |
@@ -82,2 +82,3 @@ import { parseReferenceNodes, parseSymbolNodes } from "./shared.js"; | ||
| fieldLookups: statement.uiLookups, | ||
| navpoints: statement.uiNavpoints || statement.uiRoutes, | ||
| screenRoutes: statement.uiRoutes, | ||
@@ -114,2 +115,3 @@ webHints: statement.uiWeb, | ||
| uiVisibility: statement.uiVisibility, | ||
| uiNavpoints: statement.uiNavpoints || statement.uiRoutes, | ||
| uiRoutes: statement.uiRoutes, | ||
@@ -116,0 +118,0 @@ uiWeb: statement.uiWeb, |
@@ -6,6 +6,11 @@ import { blockEntries, getFieldValue } from "../validator.js"; | ||
| return blockEntries(getFieldValue(statement, "endpoints")).map((entry) => { | ||
| const capabilityId = tokenValue(entry.items[0]); | ||
| const realizesEndpoint = tokenValue(entry.items[0]) === "endpoint"; | ||
| const endpointId = realizesEndpoint ? tokenValue(entry.items[1]) : null; | ||
| const endpoint = endpointId ? registry.get(endpointId) : null; | ||
| const capabilityId = realizesEndpoint | ||
| ? tokenValue(endpoint?.fields?.find((field) => field.key === "capability")?.value) | ||
| : tokenValue(entry.items[0]); | ||
| const directives = {}; | ||
| for (let i = 1; i < entry.items.length; i += 2) { | ||
| for (let i = realizesEndpoint ? 2 : 1; i < entry.items.length; i += 2) { | ||
| const key = tokenValue(entry.items[i]); | ||
@@ -18,4 +23,19 @@ const value = tokenValue(entry.items[i + 1]); | ||
| const endpointMethod = tokenValue(endpoint?.fields?.find((field) => field.key === "method")?.value); | ||
| const endpointPath = tokenValue(endpoint?.fields?.find((field) => field.key === "path")?.value); | ||
| const endpointSuccess = tokenValue(endpoint?.fields?.find((field) => field.key === "success")?.value); | ||
| const endpointAuth = tokenValue(endpoint?.fields?.find((field) => field.key === "auth")?.value); | ||
| const endpointRequest = tokenValue(endpoint?.fields?.find((field) => field.key === "request")?.value); | ||
| const endpointResponseResult = tokenValue(endpoint?.fields?.find((field) => field.key === "response_result")?.value); | ||
| const endpointResponseEntity = tokenValue(endpoint?.fields?.find((field) => field.key === "response_entity")?.value); | ||
| const endpointResponseContainer = tokenValue(endpoint?.fields?.find((field) => field.key === "response_container")?.value); | ||
| return { | ||
| type: "http_realization", | ||
| endpoint: endpointId | ||
| ? { | ||
| id: endpointId, | ||
| kind: endpoint?.kind || null | ||
| } | ||
| : null, | ||
| capability: capabilityId | ||
@@ -27,7 +47,15 @@ ? { | ||
| : null, | ||
| method: directives.method || null, | ||
| path: directives.path || null, | ||
| success: directives.success ? Number.parseInt(directives.success, 10) : null, | ||
| auth: directives.auth || null, | ||
| request: directives.request || null, | ||
| method: directives.method || endpointMethod || null, | ||
| path: directives.path || endpointPath || null, | ||
| success: directives.success || endpointSuccess ? Number.parseInt(directives.success || endpointSuccess, 10) : null, | ||
| auth: directives.auth || endpointAuth || null, | ||
| request: directives.request || endpointRequest || null, | ||
| responseResult: endpointResponseResult || null, | ||
| responseEntity: endpointResponseEntity | ||
| ? { | ||
| id: endpointResponseEntity, | ||
| kind: registry.get(endpointResponseEntity)?.kind || null | ||
| } | ||
| : null, | ||
| responseContainer: endpointResponseContainer || null, | ||
| raw: normalizeSequence(entry.items), | ||
@@ -34,0 +62,0 @@ loc: entry.loc |
@@ -205,7 +205,8 @@ import { blockEntries, getFieldValue, stringValue, symbolValue, symbolValues } from "../validator.js"; | ||
| export function parseProjectionUiRoutesBlock(statement, registry) { | ||
| export function parseProjectionUiNavpointsBlock(statement, registry) { | ||
| return [ | ||
| ...parseProjectionSurfaceRoutesBlock(statement, registry), | ||
| ...parseProjectionSurfaceNavpointsBlock(statement, registry), | ||
| ...blockEntries(getFieldValue(statement, "screen_routes")).map((entry) => ({ | ||
| type: "ui_route", | ||
| type: "ui_navpoint", | ||
| navpointId: null, | ||
| routeId: null, | ||
@@ -226,2 +227,4 @@ screenId: tokenValue(entry.items[1]), | ||
| export const parseProjectionUiRoutesBlock = parseProjectionUiNavpointsBlock; | ||
| export function parseProjectionUiIosBlock(statement, registry) { | ||
@@ -716,21 +719,22 @@ return blockEntries(getFieldValue(statement, "ios_hints")).map((entry) => ({ | ||
| function parseProjectionSurfaceRoutesBlock(statement, registry) { | ||
| return blockEntries(getFieldValue(statement, "routes")).map((entry) => { | ||
| const routeId = tokenValue(entry.items[1]) || null; | ||
| const route = routeId ? registry.get(routeId) : null; | ||
| function parseProjectionSurfaceNavpointsBlock(statement, registry) { | ||
| return blockEntries(getFieldValue(statement, "navpoints")).map((entry) => { | ||
| const navpointId = tokenValue(entry.items[1]) || null; | ||
| const navpoint = navpointId ? registry.get(navpointId) : null; | ||
| const directives = parseFlexibleDirectives(entry.items, 2); | ||
| const path = directives.path?.value || tokenValue(route?.fields?.find((field) => field.key === "path")?.value) || null; | ||
| const screenId = tokenValue(route?.fields?.find((field) => field.key === "screen")?.value) || null; | ||
| const loaderId = directives.loader?.value || tokenValue(route?.fields?.find((field) => field.key === "loader")?.value) || null; | ||
| const actionId = directives.action?.value || tokenValue(route?.fields?.find((field) => field.key === "action")?.value) || null; | ||
| const path = directives.path?.value || tokenValue(navpoint?.fields?.find((field) => field.key === "path")?.value) || null; | ||
| const screenId = tokenValue(navpoint?.fields?.find((field) => field.key === "screen")?.value) || null; | ||
| const loaderId = directives.loader?.value || tokenValue(navpoint?.fields?.find((field) => field.key === "loader")?.value) || null; | ||
| const actionId = directives.action?.value || tokenValue(navpoint?.fields?.find((field) => field.key === "action")?.value) || null; | ||
| return { | ||
| type: "ui_route", | ||
| routeId, | ||
| type: "ui_navpoint", | ||
| navpointId, | ||
| routeId: navpointId, | ||
| screenId, | ||
| path, | ||
| params: tokenListValues(route?.fields?.find((field) => field.key === "params")?.value), | ||
| params: tokenListValues(navpoint?.fields?.find((field) => field.key === "params")?.value), | ||
| loader: loaderId ? { id: loaderId, kind: "capability" } : null, | ||
| action: actionId ? { id: actionId, kind: "capability" } : null, | ||
| auth: directives.auth?.value || tokenValue(route?.fields?.find((field) => field.key === "auth")?.value) || null, | ||
| source: "routes", | ||
| auth: directives.auth?.value || tokenValue(navpoint?.fields?.find((field) => field.key === "auth")?.value) || null, | ||
| source: "navpoints", | ||
| derived: false, | ||
@@ -737,0 +741,0 @@ raw: normalizeSequence(entry.items), |
+46
-7
@@ -7,3 +7,4 @@ // `sdlc new <kind> <slug>` — scaffold a new SDLC `.tg` file with sensible | ||
| import { recordDirForKind } from "./kinds.js"; | ||
| import { sdlcRootForSdlc } from "./paths.js"; | ||
| import { projectRootForSdlc, sdlcRootForSdlc } from "./paths.js"; | ||
| import { loadSdlcPolicy } from "./policy.js"; | ||
@@ -41,5 +42,13 @@ const TEMPLATES = { | ||
| `, | ||
| task: (slug) => `task task_${slug} { | ||
| task: (slug, options = {}) => { | ||
| const feature = options.feature ? ` feature ${options.feature}\n` : ""; | ||
| const phase = options.phase ? ` phase ${options.phase}\n` : ""; | ||
| const scope = options.scope ? ` scope ${options.scope}\n` : ""; | ||
| const intent = options.intent ? ` intent ${tgString(options.intent)}\n` : ""; | ||
| const success = options.success ? ` success ${tgString(options.success)}\n` : ""; | ||
| return `task task_${slug} { | ||
| name "${humanize(slug)}" | ||
| description "What the agent or human will do." | ||
| ${feature}${phase}${scope}${intent}${success} non_goals [] | ||
| entrypoints [] | ||
| satisfies [] | ||
@@ -55,3 +64,4 @@ acceptance_refs [] | ||
| } | ||
| `, | ||
| `; | ||
| }, | ||
| bug: (slug) => `bug bug_${slug} { | ||
@@ -76,3 +86,13 @@ name "${humanize(slug)}" | ||
| export function scaffoldNew(workspaceRoot, kind, slug) { | ||
| function tgString(value) { | ||
| return JSON.stringify(String(value || "")); | ||
| } | ||
| function hasAdoptedSdlc(inputPath) { | ||
| const policyInfo = loadSdlcPolicy(projectRootForSdlc(inputPath)); | ||
| if (policyInfo.status === "adopted") return true; | ||
| return existsSync(sdlcRootForSdlc(inputPath)); | ||
| } | ||
| export function scaffoldNew(workspaceRoot, kind, slug, options = {}) { | ||
| if (!TEMPLATES[kind]) { | ||
@@ -84,4 +104,16 @@ return { ok: false, error: `Unsupported kind '${kind}' (allowed: ${Object.keys(TEMPLATES).join(", ")})` }; | ||
| } | ||
| if (kind === "task") { | ||
| if (!hasAdoptedSdlc(workspaceRoot)) { | ||
| return { | ||
| ok: false, | ||
| status: "needs_sdlc_adoption", | ||
| error: "SDLC task creation requires adopted SDLC.", | ||
| next_commands: [ | ||
| `topogram init ${workspaceRoot || "."} --adopt-sdlc --json`, | ||
| `topogram sdlc policy init ${workspaceRoot || "."} --json` | ||
| ] | ||
| }; | ||
| } | ||
| } | ||
| const targetDir = path.join(sdlcRootForSdlc(workspaceRoot), recordDirForKind(kind)); | ||
| if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); | ||
| const targetFile = path.join(targetDir, `${slug}.tg`); | ||
@@ -91,3 +123,8 @@ if (existsSync(targetFile)) { | ||
| } | ||
| writeFileSync(targetFile, TEMPLATES[kind](slug), "utf8"); | ||
| const source = TEMPLATES[kind](slug, options); | ||
| const write = options.write !== false; | ||
| if (write) { | ||
| if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); | ||
| writeFileSync(targetFile, source, "utf8"); | ||
| } | ||
| return { | ||
@@ -97,4 +134,6 @@ ok: true, | ||
| slug, | ||
| file: targetFile | ||
| file: targetFile, | ||
| write, | ||
| source | ||
| }; | ||
| } |
+16
-0
| // @ts-check | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
@@ -9,2 +10,4 @@ import { parsePath } from "../parser.js"; | ||
| import { transitionStatement } from "./transition.js"; | ||
| import { loadSdlcPolicy } from "./policy.js"; | ||
| import { projectRootForSdlc } from "./paths.js"; | ||
| import { readVerificationRuns } from "./verification-runs.js"; | ||
@@ -145,2 +148,15 @@ import { buildSdlcStartPacket } from "./views.js"; | ||
| const sdlcRoot = resolveTopoRoot(workspaceRoot || "."); | ||
| const policyInfo = loadSdlcPolicy(projectRootForSdlc(sdlcRoot)); | ||
| const hasAdoptedLayout = fs.existsSync(path.join(sdlcRoot, "sdlc")); | ||
| if (policyInfo.status !== "adopted" && !hasAdoptedLayout) { | ||
| return { | ||
| ok: false, | ||
| status: "needs_sdlc_adoption", | ||
| error: "SDLC task start requires adopted SDLC.", | ||
| next_commands: [ | ||
| `topogram init ${workspaceRoot || "."} --adopt-sdlc --json`, | ||
| `topogram sdlc policy init ${workspaceRoot || "."} --json` | ||
| ] | ||
| }; | ||
| } | ||
| if (options.write && !options.dryRun && !options.locked) { | ||
@@ -147,0 +163,0 @@ return withSdlcStateLock(sdlcRoot, "sdlc start", () => |
@@ -189,2 +189,87 @@ // @ts-check | ||
| /** | ||
| * @param {unknown} input | ||
| * @returns {AnyRecord[]} | ||
| */ | ||
| function receiptArray(input) { | ||
| if (Array.isArray(input)) return /** @type {AnyRecord[]} */ (input); | ||
| if (input && typeof input === "object" && Array.isArray(/** @type {AnyRecord} */ (input).receipts)) { | ||
| return /** @type {AnyRecord[]} */ (/** @type {AnyRecord} */ (input).receipts); | ||
| } | ||
| return []; | ||
| } | ||
| /** | ||
| * @param {AnyRecord} receipt | ||
| * @param {AnyRecord} defaults | ||
| * @returns {AnyRecord} | ||
| */ | ||
| function normalizeReceiptInput(receipt, defaults = {}) { | ||
| const source = /** @type {AnyRecord} */ (receipt && typeof receipt === "object" ? receipt : {}); | ||
| return { | ||
| verificationId: stringOrNull(source.verification_id) | ||
| || stringOrNull(source.verificationId) | ||
| || stringOrNull(source.verification) | ||
| || null, | ||
| taskId: stringOrNull(source.task_id) | ||
| || stringOrNull(source.taskId) | ||
| || stringOrNull(defaults.taskId) | ||
| || null, | ||
| actor: stringOrNull(source.actor) | ||
| || stringOrNull(defaults.actor) | ||
| || null, | ||
| command: stringOrNull(source.command) | ||
| || null, | ||
| status: stringOrNull(source.status) | ||
| || stringOrNull(defaults.status) | ||
| || null, | ||
| artifactPath: stringOrNull(source.artifact_path) | ||
| || stringOrNull(source.artifactPath) | ||
| || stringOrNull(source.artifact) | ||
| || null, | ||
| summary: stringOrNull(source.summary) | ||
| || null, | ||
| commit: stringOrNull(source.commit) | ||
| || stringOrNull(defaults.commit) | ||
| || null | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} filePath | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function loadVerificationReceiptBatchFile(filePath) { | ||
| const resolvedPath = path.resolve(process.cwd(), filePath || ""); | ||
| if (!filePath) { | ||
| return { ok: false, error: "sdlc verify record-batch requires --from-file <json>" }; | ||
| } | ||
| if (!fs.existsSync(resolvedPath)) { | ||
| return { ok: false, error: `verification receipt batch file not found: ${filePath}` }; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); | ||
| } catch (error) { | ||
| return { | ||
| ok: false, | ||
| error: `verification receipt batch file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, | ||
| path: resolvedPath | ||
| }; | ||
| } | ||
| const receipts = receiptArray(parsed); | ||
| if (receipts.length === 0) { | ||
| return { | ||
| ok: false, | ||
| error: "verification receipt batch file must contain a JSON array or an object with receipts[]", | ||
| path: resolvedPath | ||
| }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| path: resolvedPath, | ||
| receipts | ||
| }; | ||
| } | ||
| /** | ||
| * @param {string} workspaceRoot | ||
@@ -280,1 +365,130 @@ * @param {string} verificationId | ||
| } | ||
| /** | ||
| * @param {string} workspaceRoot | ||
| * @param {AnyRecord[]} receipts | ||
| * @param {{ | ||
| * taskId?: string|null, | ||
| * actor?: string|null, | ||
| * status?: string|null, | ||
| * commit?: string|null, | ||
| * write?: boolean, | ||
| * locked?: boolean, | ||
| * sourceFile?: string|null | ||
| * }} options | ||
| * @returns {AnyRecord} | ||
| */ | ||
| export function recordVerificationRunBatch(workspaceRoot, receipts, options = {}) { | ||
| const topogramRoot = topogramRootForSdlc(workspaceRoot); | ||
| if (options.write && !options.locked) { | ||
| return withSdlcStateLock(topogramRoot, "sdlc verify record-batch", () => | ||
| recordVerificationRunBatch(topogramRoot, receipts, { ...options, locked: true }) | ||
| ); | ||
| } | ||
| const normalized = (Array.isArray(receipts) ? receipts : []) | ||
| .map((receipt) => normalizeReceiptInput(receipt, options)); | ||
| if (normalized.length === 0) { | ||
| return { | ||
| ok: false, | ||
| type: "verification_run_batch_record", | ||
| version: 1, | ||
| dryRun: !options.write, | ||
| error: "sdlc verify record-batch requires at least one receipt" | ||
| }; | ||
| } | ||
| /** @type {AnyRecord[]} */ | ||
| const validationErrors = []; | ||
| /** @type {AnyRecord[]} */ | ||
| const previews = []; | ||
| normalized.forEach((receipt, index) => { | ||
| const preview = recordVerificationRun(topogramRoot, receipt.verificationId, { | ||
| taskId: receipt.taskId, | ||
| actor: receipt.actor, | ||
| command: receipt.command, | ||
| status: receipt.status, | ||
| artifactPath: receipt.artifactPath, | ||
| summary: receipt.summary, | ||
| commit: receipt.commit, | ||
| write: false, | ||
| locked: true | ||
| }); | ||
| if (!preview.ok) { | ||
| validationErrors.push({ | ||
| index, | ||
| verification_id: receipt.verificationId || null, | ||
| task_id: receipt.taskId || null, | ||
| errors: preview.errors || [preview.error || "invalid verification receipt"] | ||
| }); | ||
| return; | ||
| } | ||
| previews.push(preview.receipt); | ||
| }); | ||
| if (validationErrors.length > 0) { | ||
| return { | ||
| ok: false, | ||
| type: "verification_run_batch_record", | ||
| version: 1, | ||
| dryRun: !options.write, | ||
| source_file: options.sourceFile || null, | ||
| receipt_count: normalized.length, | ||
| written_count: 0, | ||
| errors: validationErrors | ||
| }; | ||
| } | ||
| if (!options.write) { | ||
| return { | ||
| ok: true, | ||
| type: "verification_run_batch_record", | ||
| version: 1, | ||
| dryRun: true, | ||
| source_file: options.sourceFile || null, | ||
| receipt_count: normalized.length, | ||
| written_count: 0, | ||
| receipts: previews, | ||
| failures: [] | ||
| }; | ||
| } | ||
| /** @type {AnyRecord[]} */ | ||
| const written = []; | ||
| /** @type {AnyRecord[]} */ | ||
| const failures = []; | ||
| normalized.forEach((receipt, index) => { | ||
| const result = recordVerificationRun(topogramRoot, receipt.verificationId, { | ||
| taskId: receipt.taskId, | ||
| actor: receipt.actor, | ||
| command: receipt.command, | ||
| status: receipt.status, | ||
| artifactPath: receipt.artifactPath, | ||
| summary: receipt.summary, | ||
| commit: receipt.commit, | ||
| write: true, | ||
| locked: true | ||
| }); | ||
| if (result.ok) { | ||
| written.push(result.receipt); | ||
| } else { | ||
| failures.push({ | ||
| index, | ||
| verification_id: receipt.verificationId || null, | ||
| task_id: receipt.taskId || null, | ||
| errors: result.errors || [result.error || "failed to write verification receipt"] | ||
| }); | ||
| } | ||
| }); | ||
| return { | ||
| ok: failures.length === 0, | ||
| type: "verification_run_batch_record", | ||
| version: 1, | ||
| dryRun: false, | ||
| source_file: options.sourceFile || null, | ||
| receipt_count: normalized.length, | ||
| written_count: written.length, | ||
| receipts: written, | ||
| failures | ||
| }; | ||
| } |
@@ -74,3 +74,3 @@ // @ts-check | ||
| ["ui_design", ["design_tokens", "design_tokens { density comfortable tone operational }"]], | ||
| ["ui_routes", ["screen_routes", "screen_routes { screen item_list path /items }"]], | ||
| ["ui_routes", ["navpoints", "navpoints { navpoint nav_item_list }"]], | ||
| ["ui_screens", ["screens", "screens [screen_item_list]"]], | ||
@@ -84,2 +84,3 @@ ["ui_screen_regions", ["screen_regions", "screen_regions { screen item_list region results pattern resource_table }"]], | ||
| ["ui_lookups", ["field_lookups", "field_lookups { field owner_id source cap_list_users }"]], | ||
| ["routes", ["navpoints", "navpoints { navpoint nav_item_list }"]], | ||
| ["web", ["web_hints", "web_hints { router file_based }"]], | ||
@@ -222,2 +223,5 @@ ["ios", ["ios_hints", "ios_hints { navigation stack }"]], | ||
| ensureSingleValueField(errors, statement, fieldMap, "change_type", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "feature", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "phase", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "scope", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "task", ["symbol"]); | ||
@@ -228,2 +232,3 @@ ensureSingleValueField(errors, statement, fieldMap, "version", ["string"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "outcome", ["string"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "intent", ["string"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "goal", ["string"]); | ||
@@ -238,5 +243,7 @@ ensureSingleValueField(errors, statement, fieldMap, "trigger", ["string"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "screen", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "capability", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "loader", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "action", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "auth", ["symbol"]); | ||
| ensureSingleValueField(errors, statement, fieldMap, "request", ["symbol"]); | ||
| for (const key of ["load", "submit", "detail_capability", "primary_action", "secondary_action", "destructive_action", "terminal_action", "input_shape", "view_shape", "item_shape", "success_navigate", "success_refresh", "loading_state", "empty_state", "error_state", "unauthorized_state", "not_found_state", "success_state"]) { | ||
@@ -301,3 +308,6 @@ ensureSingleValueField(errors, statement, fieldMap, key, ["symbol"]); | ||
| "related_decisions", | ||
| "related_docs" | ||
| "related_docs", | ||
| "verification_refs", | ||
| "non_goals", | ||
| "entrypoints" | ||
| ]; | ||
@@ -315,3 +325,3 @@ for (const key of listFields) { | ||
| const blockFields = ["fields", "props", "events", "slots", "behaviors", "keys", "relations", "invariants", "rename", "overrides", "endpoints", "error_responses", "wire_fields", "responses", "preconditions", "idempotency", "cache", "delete_semantics", "async_jobs", "async_status", "downloads", "authorization", "callbacks", "commands", "command_options", "command_outputs", "command_effects", "command_examples", "routes", "collection_views", "screen_actions", "visibility_rules", "field_lookups", "screen_routes", "web_hints", "ios_hints", "navigation", "screen_regions", "renders", "widget_bindings", "design_tokens", "messages", "token_mappings", "tables", "columns", "keys", "indexes", "relations", "lifecycle", "generator_defaults"]; | ||
| const blockFields = ["fields", "props", "events", "slots", "behaviors", "keys", "relations", "invariants", "rename", "overrides", "error_responses", "wire_fields", "responses", "preconditions", "idempotency", "cache", "delete_semantics", "async_jobs", "async_status", "downloads", "authorization", "callbacks", "commands", "command_options", "command_outputs", "command_effects", "command_examples", "routes", "navpoints", "collection_views", "screen_actions", "visibility_rules", "field_lookups", "screen_routes", "web_hints", "ios_hints", "navigation", "screen_regions", "renders", "widget_bindings", "design_tokens", "messages", "token_mappings", "tables", "columns", "keys", "indexes", "relations", "lifecycle", "generator_defaults"]; | ||
| if (statement.kind === "plan") { | ||
@@ -386,3 +396,4 @@ blockFields.push("steps"); | ||
| ensureSingleValueField(errors, statement, fieldMap, "accessibility", ["block"]); | ||
| validateBlockEntryLengths(errors, statement, fieldMap, "endpoints", 7); | ||
| ensureSingleValueField(errors, statement, fieldMap, "endpoints", ["block"]); | ||
| validateBlockEntryLengths(errors, statement, fieldMap, "endpoints", 2); | ||
| validateBlockEntryLengths(errors, statement, fieldMap, "error_responses", 3); | ||
@@ -406,2 +417,3 @@ validateBlockEntryLengths(errors, statement, fieldMap, "wire_fields", 5); | ||
| validateBlockEntryLengths(errors, statement, fieldMap, "routes", 2); | ||
| validateBlockEntryLengths(errors, statement, fieldMap, "navpoints", 2); | ||
| validateBlockEntryLengths(errors, statement, fieldMap, "collection_views", 4); | ||
@@ -545,2 +557,3 @@ validateBlockEntryLengths(errors, statement, fieldMap, "screen_actions", 6); | ||
| entity: ["entity"], | ||
| capability: ["capability"], | ||
| theme: ["theme"], | ||
@@ -553,3 +566,3 @@ screen: ["screen"], | ||
| dependencies: [...STATEMENT_KINDS], | ||
| realizes: ["capability", "surface", "entity"], | ||
| realizes: ["capability", "surface", "entity", "endpoint"], | ||
| layout: ["layout"], | ||
@@ -574,3 +587,4 @@ context_shapes: ["shape"], | ||
| from_requirement: ["requirement"], | ||
| affects: ["capability", "entity", "rule", "surface", "widget", "section", "route", "screen", "layout", "region", "theme", "design_language", "component_map"], | ||
| affects: ["capability", "entity", "rule", "surface", "widget", "section", "navpoint", "endpoint", "screen", "layout", "region", "theme", "design_language", "component_map"], | ||
| feature: ["feature"], | ||
| related_capabilities: ["capability"], | ||
@@ -577,0 +591,0 @@ related_entities: ["entity"], |
@@ -14,2 +14,3 @@ // @ts-check | ||
| } from "./model-helpers.js"; | ||
| import { validatePortableIdentifier } from "./safe-values.js"; | ||
@@ -19,2 +20,24 @@ /** | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @returns {void} | ||
| */ | ||
| function validateFieldIdentifiers(errors, statement, fieldMap) { | ||
| if (statement.kind !== "entity" && statement.kind !== "shape") { | ||
| return; | ||
| } | ||
| const fields = fieldMap.get("fields")?.[0]; | ||
| if (!fields || fields.value.type !== "block") { | ||
| return; | ||
| } | ||
| for (const entry of fields.value.entries) { | ||
| const fieldName = entry.items[0]?.value; | ||
| validatePortableIdentifier(errors, `${statement.kind} ${statement.id} field name`, fieldName, entry.items[0]?.loc || entry.loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramRegistry} registry | ||
@@ -143,2 +166,4 @@ * @returns {void} | ||
| const [fromItem, toItem] = items; | ||
| validatePortableIdentifier(errors, `Shape ${statement.id} rename target`, toItem.value, toItem.loc); | ||
| if (!baseFieldSet.has(fromItem.value)) { | ||
@@ -238,4 +263,5 @@ pushError(errors, `Shape ${statement.id} renames unknown field '${fromItem.value}'`, fromItem.loc); | ||
| export function validateDataModelStatement(errors, statement, fieldMap, registry) { | ||
| validateFieldIdentifiers(errors, statement, fieldMap); | ||
| validateEntityRelations(errors, statement, fieldMap, registry); | ||
| validateShapeTransforms(errors, statement, fieldMap, registry); | ||
| } |
@@ -26,2 +26,3 @@ import { validateCoreStatement, validateReferenceRules } from "./common.js"; | ||
| import { validateAcceptanceCriterion } from "./per-kind/acceptance-criterion.js"; | ||
| import { validateFeature } from "./per-kind/feature.js"; | ||
| import { validateTask } from "./per-kind/task.js"; | ||
@@ -32,3 +33,3 @@ import { validatePlan } from "./per-kind/plan.js"; | ||
| import { validateWorkflow } from "./per-kind/workflow.js"; | ||
| import { validateRoute, validateSection } from "./per-kind/route.js"; | ||
| import { validateEndpoint, validateNavpoint, validateSection } from "./per-kind/navpoint.js"; | ||
@@ -43,2 +44,3 @@ export { | ||
| ACCEPTANCE_CRITERION_IDENTIFIER_PATTERN, | ||
| FEATURE_IDENTIFIER_PATTERN, | ||
| TASK_IDENTIFIER_PATTERN, | ||
@@ -62,2 +64,3 @@ PLAN_IDENTIFIER_PATTERN, | ||
| ACCEPTANCE_CRITERION_STATUSES, | ||
| FEATURE_STATUSES, | ||
| TASK_STATUSES, | ||
@@ -67,2 +70,4 @@ PLAN_STATUSES, | ||
| BUG_STATUSES, | ||
| TASK_PHASES, | ||
| TASK_SCOPES, | ||
| JOURNEY_STATUSES, | ||
@@ -134,3 +139,4 @@ PRIORITY_VALUES, | ||
| validateSection(errors, statement, fieldMap); | ||
| validateRoute(errors, statement, fieldMap, registry); | ||
| validateNavpoint(errors, statement, fieldMap, registry); | ||
| validateEndpoint(errors, statement, fieldMap, registry); | ||
| validateRegionContract(errors, statement, fieldMap); | ||
@@ -145,2 +151,3 @@ validateLayoutContract(errors, statement, fieldMap, registry); | ||
| validateAcceptanceCriterion(errors, statement, fieldMap, registry); | ||
| validateFeature(errors, statement, fieldMap, registry); | ||
| validateTask(errors, statement, fieldMap, registry); | ||
@@ -147,0 +154,0 @@ validatePlan(errors, statement, fieldMap, registry); |
@@ -8,2 +8,3 @@ export const STATEMENT_KINDS: Set<string>; | ||
| export const ACCEPTANCE_CRITERION_IDENTIFIER_PATTERN: RegExp; | ||
| export const FEATURE_IDENTIFIER_PATTERN: RegExp; | ||
| export const TASK_IDENTIFIER_PATTERN: RegExp; | ||
@@ -30,2 +31,4 @@ export const PLAN_IDENTIFIER_PATTERN: RegExp; | ||
| export const TASK_RISK_CLASSES: Set<string>; | ||
| export const TASK_PHASES: Set<string>; | ||
| export const TASK_SCOPES: Set<string>; | ||
| export const TASK_CHANGE_TYPES: Set<string>; | ||
@@ -35,2 +38,3 @@ export const PLAN_STATUSES: Set<string>; | ||
| export const BUG_STATUSES: Set<string>; | ||
| export const FEATURE_STATUSES: Set<string>; | ||
| export const JOURNEY_STATUSES: Set<string>; | ||
@@ -37,0 +41,0 @@ export const PRIORITY_VALUES: Set<string>; |
@@ -16,3 +16,4 @@ // @ts-check | ||
| "section", | ||
| "route", | ||
| "navpoint", | ||
| "endpoint", | ||
| "screen", | ||
@@ -29,2 +30,3 @@ "region", | ||
| "workflow", | ||
| "feature", | ||
| "pitch", | ||
@@ -43,2 +45,3 @@ "requirement", | ||
| export const ACCEPTANCE_CRITERION_IDENTIFIER_PATTERN = /^ac_[a-z][a-z0-9_]*$/; | ||
| export const FEATURE_IDENTIFIER_PATTERN = /^feature_[a-z][a-z0-9_]*$/; | ||
| export const TASK_IDENTIFIER_PATTERN = /^task_[a-z][a-z0-9_]*$/; | ||
@@ -64,5 +67,8 @@ export const PLAN_IDENTIFIER_PATTERN = /^plan_[a-z][a-z0-9_]*$/; | ||
| export const BUG_STATUSES = new Set(["open", "in-progress", "fixed", "verified", "wont-fix"]); | ||
| export const FEATURE_STATUSES = new Set(["draft", "active", "deprecated"]); | ||
| export const JOURNEY_STATUSES = new Set(["draft", "canonical", "active", "deprecated"]); | ||
| export const TASK_DISPOSITIONS = new Set(["active", "follow_up", "deferred", "backlog", "blocker"]); | ||
| export const TASK_RISK_CLASSES = new Set(["low", "medium", "high", "critical"]); | ||
| export const TASK_PHASES = new Set(["modeling", "implementation", "verification", "polish", "release"]); | ||
| export const TASK_SCOPES = new Set(["current_feature", "cross_cutting", "maintenance", "bugfix"]); | ||
| export const TASK_CHANGE_TYPES = new Set([ | ||
@@ -112,2 +118,3 @@ "docs", | ||
| acceptance_criterion: ACCEPTANCE_CRITERION_STATUSES, | ||
| feature: FEATURE_STATUSES, | ||
| task: TASK_STATUSES, | ||
@@ -178,3 +185,4 @@ plan: PLAN_STATUSES, | ||
| "section", | ||
| "route", | ||
| "navpoint", | ||
| "endpoint", | ||
| "screen", | ||
@@ -189,2 +197,3 @@ "region", | ||
| "workflow", | ||
| "feature", | ||
| "pitch", | ||
@@ -261,6 +270,10 @@ "requirement", | ||
| }, | ||
| route: { | ||
| navpoint: { | ||
| required: ["name", "description", "path", "screen", "status"], | ||
| allowed: ["name", "description", "path", "params", "screen", "loader", "action", "auth", "related_terms", "domain", "status"] | ||
| }, | ||
| endpoint: { | ||
| required: ["name", "description", "method", "path", "capability", "success", "auth", "request", "status"], | ||
| allowed: ["name", "description", "method", "path", "params", "capability", "success", "auth", "request", "response_result", "response_entity", "response_container", "related_terms", "domain", "status"] | ||
| }, | ||
| screen: { | ||
@@ -350,3 +363,3 @@ required: ["name", "description", "kind", "layout", "title", "status"], | ||
| "command_examples", | ||
| "routes", | ||
| "navpoints", | ||
| "screens", | ||
@@ -452,2 +465,18 @@ "collection_views", | ||
| }, | ||
| feature: { | ||
| required: ["name", "description", "intent", "status"], | ||
| allowed: [ | ||
| "name", | ||
| "description", | ||
| "intent", | ||
| "entities", | ||
| "capabilities", | ||
| "endpoints", | ||
| "seed_data", | ||
| "verification_refs", | ||
| "related_terms", | ||
| "domain", | ||
| "status" | ||
| ] | ||
| }, | ||
| pitch: { | ||
@@ -506,2 +535,9 @@ required: ["name", "description", "status", "priority"], | ||
| "change_type", | ||
| "feature", | ||
| "phase", | ||
| "scope", | ||
| "intent", | ||
| "success", | ||
| "non_goals", | ||
| "entrypoints", | ||
| "affects", | ||
@@ -508,0 +544,0 @@ "satisfies", |
@@ -6,4 +6,6 @@ // @ts-check | ||
| TASK_CHANGE_TYPES, | ||
| TASK_PHASES, | ||
| PRIORITY_VALUES, | ||
| TASK_RISK_CLASSES, | ||
| TASK_SCOPES, | ||
| WORK_TYPES | ||
@@ -105,2 +107,37 @@ } from "../kinds.js"; | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */ | ||
| function validatePhase(errors, statement, fieldMap) { | ||
| const field = fieldMap.get("phase")?.[0]; | ||
| if (!field) return; | ||
| if (field.value.type !== "symbol") { | ||
| pushError(errors, `Field 'phase' on task ${statement.id} must be a symbol`, field.loc); | ||
| return; | ||
| } | ||
| if (!TASK_PHASES.has(field.value.value)) { | ||
| pushError(errors, `Invalid phase '${field.value.value}' on task ${statement.id}`, field.loc); | ||
| } | ||
| } | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */ | ||
| function validateScope(errors, statement, fieldMap) { | ||
| const field = fieldMap.get("scope")?.[0]; | ||
| if (!field) return; | ||
| if (field.value.type !== "symbol") { | ||
| pushError(errors, `Field 'scope' on task ${statement.id} must be a symbol`, field.loc); | ||
| return; | ||
| } | ||
| if (!TASK_SCOPES.has(field.value.value)) { | ||
| pushError(errors, `Invalid scope '${field.value.value}' on task ${statement.id}`, field.loc); | ||
| } | ||
| } | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */ | ||
| function validateSuccess(errors, statement, fieldMap) { | ||
| const field = fieldMap.get("success")?.[0]; | ||
| if (!field) return; | ||
| if (field.value.type !== "string") { | ||
| pushError(errors, `Field 'success' on task ${statement.id} must be a string`, field.loc); | ||
| } | ||
| } | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap @param {TopogramRegistry} registry */ | ||
@@ -152,4 +189,7 @@ function validateBlockingPair(errors, statement, fieldMap, registry) { | ||
| validateChangeType(errors, statement, fieldMap); | ||
| validatePhase(errors, statement, fieldMap); | ||
| validateScope(errors, statement, fieldMap); | ||
| validateSuccess(errors, statement, fieldMap); | ||
| validateBlockingPair(errors, statement, fieldMap, registry); | ||
| validateClaimedByPresence(errors, statement, fieldMap); | ||
| } |
@@ -10,2 +10,8 @@ // @ts-check | ||
| } from "../utils.js"; | ||
| import { | ||
| validateDownloadFilename, | ||
| validateDownloadMediaType, | ||
| validateGeneratedHttpPath, | ||
| validateHttpHeaderName | ||
| } from "../safe-values.js"; | ||
| import { resolveCapabilityContractFields } from "./helpers.js"; | ||
@@ -90,2 +96,4 @@ | ||
| } | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} async_jobs location_header for '${capabilityId}'`, directives.get("location_header"), entry.loc); | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} async_jobs retry_after_header for '${capabilityId}'`, directives.get("retry_after_header"), entry.loc); | ||
@@ -126,2 +134,3 @@ const jobShapeId = directives.get("job"); | ||
| } | ||
| validateGeneratedHttpPath(errors, `Projection ${statement.id} async_jobs status_path for '${capabilityId}'`, statusPath, entry.loc); | ||
| } | ||
@@ -313,5 +322,4 @@ } | ||
| const media = directives.get("media"); | ||
| if (media && !media.includes("/")) { | ||
| pushError(errors, `Projection ${statement.id} downloads for '${capabilityId}' must use a valid media type`, entry.loc); | ||
| } | ||
| validateDownloadMediaType(errors, `Projection ${statement.id} downloads media for '${capabilityId}'`, media, entry.loc); | ||
| validateDownloadFilename(errors, `Projection ${statement.id} downloads filename for '${capabilityId}'`, directives.get("filename"), entry.loc); | ||
@@ -318,0 +326,0 @@ const disposition = directives.get("disposition"); |
@@ -9,2 +9,6 @@ // @ts-check | ||
| } from "../utils.js"; | ||
| import { | ||
| validateGeneratedHttpPath, | ||
| validateHttpHeaderName | ||
| } from "../safe-values.js"; | ||
| import { resolveCapabilityContractFields } from "./helpers.js"; | ||
@@ -33,3 +37,8 @@ | ||
| const tokens = blockSymbolItems(entry).map((item) => item.value); | ||
| const capabilityId = tokens[0]; | ||
| const realizesEndpoint = tokens[0] === "endpoint"; | ||
| const endpointId = realizesEndpoint ? tokens[1] : null; | ||
| const endpoint = endpointId ? registry.get(endpointId) : null; | ||
| const capabilityId = realizesEndpoint | ||
| ? endpoint?.fields?.find((field) => field.key === "capability")?.value?.value | ||
| : tokens[0]; | ||
| if (!capabilityId) { | ||
@@ -39,2 +48,13 @@ continue; | ||
| if (realizesEndpoint) { | ||
| if (!endpoint) { | ||
| pushError(errors, `Projection ${statement.id} endpoints references missing endpoint '${endpointId}'`, entry.loc); | ||
| continue; | ||
| } | ||
| if (endpoint.kind !== "endpoint") { | ||
| pushError(errors, `Projection ${statement.id} endpoints must reference endpoint records, found ${endpoint.kind} '${endpoint.id}'`, entry.loc); | ||
| continue; | ||
| } | ||
| } | ||
| const target = registry.get(capabilityId); | ||
@@ -53,3 +73,3 @@ if (!target) { | ||
| const directives = new Map(); | ||
| for (let i = 1; i < tokens.length; i += 2) { | ||
| for (let i = realizesEndpoint ? 2 : 1; i < tokens.length; i += 2) { | ||
| const key = tokens[i]; | ||
@@ -65,3 +85,4 @@ const value = tokens[i + 1]; | ||
| for (const requiredKey of ["method", "path", "success"]) { | ||
| if (!directives.has(requiredKey)) { | ||
| const endpointHasKey = realizesEndpoint && endpoint?.fields?.some((field) => field.key === requiredKey); | ||
| if (!directives.has(requiredKey) && !endpointHasKey) { | ||
| pushError(errors, `Projection ${statement.id} http metadata for '${capabilityId}' must include '${requiredKey}'`, entry.loc); | ||
@@ -77,3 +98,3 @@ } | ||
| const method = directives.get("method"); | ||
| const method = directives.get("method") || endpoint?.fields?.find((field) => field.key === "method")?.value?.value; | ||
| if (method && !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) { | ||
@@ -83,8 +104,9 @@ pushError(errors, `Projection ${statement.id} http metadata for '${capabilityId}' has invalid method '${method}'`, entry.loc); | ||
| const path = directives.get("path"); | ||
| const path = directives.get("path") || endpoint?.fields?.find((field) => field.key === "path")?.value?.value; | ||
| if (path && !path.startsWith("/")) { | ||
| pushError(errors, `Projection ${statement.id} http metadata for '${capabilityId}' must use an absolute path`, entry.loc); | ||
| } | ||
| validateGeneratedHttpPath(errors, `Projection ${statement.id} endpoint path for '${capabilityId}'`, path, entry.loc); | ||
| const success = directives.get("success"); | ||
| const success = directives.get("success") || endpoint?.fields?.find((field) => field.key === "success")?.value?.value; | ||
| if (success && !/^\d{3}$/.test(success)) { | ||
@@ -94,3 +116,3 @@ pushError(errors, `Projection ${statement.id} http metadata for '${capabilityId}' must use a 3-digit success status`, entry.loc); | ||
| const auth = directives.get("auth"); | ||
| const auth = directives.get("auth") || endpoint?.fields?.find((field) => field.key === "auth")?.value?.value; | ||
| if (auth && !["none", "user", "manager", "admin"].includes(auth)) { | ||
@@ -100,3 +122,3 @@ pushError(errors, `Projection ${statement.id} http metadata for '${capabilityId}' has invalid auth mode '${auth}'`, entry.loc); | ||
| const request = directives.get("request"); | ||
| const request = directives.get("request") || endpoint?.fields?.find((field) => field.key === "request")?.value?.value; | ||
| if (request && !["body", "query", "path", "none"].includes(request)) { | ||
@@ -200,2 +222,5 @@ pushError(errors, `Projection ${statement.id} http metadata for '${capabilityId}' has invalid request placement '${request}'`, entry.loc); | ||
| } | ||
| if (location === "header") { | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} wire_fields header name for '${capabilityId}.${fieldName}'`, maybeWireName || fieldName, entry.loc); | ||
| } | ||
@@ -202,0 +227,0 @@ const availableFields = resolveCapabilityContractFields(registry, capabilityId, direction); |
@@ -10,2 +10,3 @@ // @ts-check | ||
| } from "../utils.js"; | ||
| import { validateHttpHeaderName } from "../safe-values.js"; | ||
| import { resolveCapabilityContractFields } from "./helpers.js"; | ||
@@ -75,2 +76,4 @@ | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} preconditions header for '${capabilityId}'`, directives.get("header"), entry.loc); | ||
| const errorStatus = directives.get("error"); | ||
@@ -152,2 +155,4 @@ if (errorStatus && !/^\d{3}$/.test(errorStatus)) { | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} idempotency header for '${capabilityId}'`, directives.get("header"), entry.loc); | ||
| const errorStatus = directives.get("error"); | ||
@@ -237,2 +242,5 @@ if (errorStatus && !/^\d{3}$/.test(errorStatus)) { | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} cache response_header for '${capabilityId}'`, directives.get("response_header"), entry.loc); | ||
| validateHttpHeaderName(errors, `Projection ${statement.id} cache request_header for '${capabilityId}'`, directives.get("request_header"), entry.loc); | ||
| const notModifiedStatus = directives.get("not_modified"); | ||
@@ -239,0 +247,0 @@ if (notModifiedStatus && notModifiedStatus !== "304") { |
@@ -10,2 +10,3 @@ // @ts-check | ||
| import { statementFieldNames } from "../model-helpers.js"; | ||
| import { validatePortableIdentifier } from "../safe-values.js"; | ||
| import { parseUiDirectiveMap } from "./helpers.js"; | ||
@@ -54,2 +55,4 @@ | ||
| pushError(errors, `Projection ${statement.id} tables has duplicate table name '${tableName}'`, entry.loc); | ||
| } else { | ||
| validatePortableIdentifier(errors, `Projection ${statement.id} table name`, tableName, entry.loc); | ||
| } | ||
@@ -78,2 +81,3 @@ seenTables.add(tableName); | ||
| const realized = new Set(symbolValues(getFieldValue(statement, "realizes"))); | ||
| const columnMappings = new Map(); | ||
| for (const entry of dbColumnsField.value.entries) { | ||
@@ -106,4 +110,28 @@ const tokens = blockSymbolItems(entry).map((item) => item.value); | ||
| pushError(errors, `Projection ${statement.id} columns for '${entityId}.${fieldName}' must include a column name`, entry.loc); | ||
| } else { | ||
| validatePortableIdentifier(errors, `Projection ${statement.id} column name`, columnName, entry.loc); | ||
| columnMappings.set(`${entityId}:${fieldName}`, columnName); | ||
| } | ||
| } | ||
| for (const entityId of realized) { | ||
| const entity = registry.get(entityId); | ||
| if (!entity || entity.kind !== "entity") { | ||
| continue; | ||
| } | ||
| const seenColumns = new Map(); | ||
| for (const fieldName of statementFieldNames(entity)) { | ||
| const columnName = columnMappings.get(`${entityId}:${fieldName}`) || fieldName; | ||
| const existingField = seenColumns.get(columnName); | ||
| if (existingField) { | ||
| pushError( | ||
| errors, | ||
| `Projection ${statement.id} maps ${entityId}.${fieldName} and ${entityId}.${existingField} to duplicate DB column '${columnName}'`, | ||
| dbColumnsField.loc | ||
| ); | ||
| } else { | ||
| seenColumns.set(columnName, fieldName); | ||
| } | ||
| } | ||
| } | ||
| } | ||
@@ -110,0 +138,0 @@ |
@@ -34,3 +34,3 @@ // @ts-check | ||
| import { parseUiDirectiveMap } from "./helpers.js"; | ||
| import { pathValue, ROUTE_AUTH_MODES, routePathParams } from "../per-kind/route.js"; | ||
| import { NAVPOINT_AUTH_MODES, pathParams, pathValue } from "../per-kind/navpoint.js"; | ||
@@ -57,3 +57,3 @@ const CONCRETE_UI_SURFACE_TYPES = new Set(["web", "ios", "android"]); | ||
| const availableScreens = collectAvailableUiScreenIds(statement, fieldMap, registry); | ||
| const availableRoutes = collectSurfaceRouteIds(statement, fieldMap); | ||
| const availableNavpoints = collectSurfaceNavpointIds(statement, fieldMap); | ||
| const groups = new Set(); | ||
@@ -86,6 +86,10 @@ | ||
| if (targetKind === "screen" || targetKind === "route") { | ||
| if (targetKind === "screen" || targetKind === "navpoint" || targetKind === "route") { | ||
| if (targetKind === "route") { | ||
| if (!availableRoutes.has(targetId)) { | ||
| pushError(errors, `Projection ${statement.id} navigation references route '${targetId}' that is not declared in routes`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navigation entries must use 'navpoint' instead of 'route'`, entry.loc); | ||
| continue; | ||
| } | ||
| if (targetKind === "navpoint") { | ||
| if (!availableNavpoints.has(targetId)) { | ||
| pushError(errors, `Projection ${statement.id} navigation references navpoint '${targetId}' that is not declared in navpoints`, entry.loc); | ||
| } | ||
@@ -123,4 +127,4 @@ } | ||
| } | ||
| if (targetKind === "route" && breadcrumb && breadcrumb !== "none" && !availableRoutes.has(breadcrumb)) { | ||
| pushError(errors, `Projection ${statement.id} navigation route '${targetId}' references unknown breadcrumb route '${breadcrumb}'`, entry.loc); | ||
| if (targetKind === "navpoint" && breadcrumb && breadcrumb !== "none" && !availableNavpoints.has(breadcrumb)) { | ||
| pushError(errors, `Projection ${statement.id} navigation navpoint '${targetId}' references unknown breadcrumb navpoint '${breadcrumb}'`, entry.loc); | ||
| } | ||
@@ -130,3 +134,3 @@ continue; | ||
| pushError(errors, `Projection ${statement.id} navigation entries must start with 'group', 'screen', or 'route'`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navigation entries must start with 'group', 'screen', or 'navpoint'`, entry.loc); | ||
| } | ||
@@ -136,3 +140,3 @@ | ||
| const tokens = blockSymbolItems(entry).map((item) => item.value); | ||
| if (tokens[0] !== "screen" && tokens[0] !== "route") { | ||
| if (tokens[0] !== "screen" && tokens[0] !== "navpoint") { | ||
| continue; | ||
@@ -217,4 +221,5 @@ } | ||
| const surfaceRoutesField = fieldMap.get("routes")?.[0]; | ||
| const surfaceNavpointsField = fieldMap.get("navpoints")?.[0]; | ||
| const routesField = fieldMap.get("screen_routes")?.[0]; | ||
| if ((!routesField || routesField.value.type !== "block") && (!surfaceRoutesField || surfaceRoutesField.value.type !== "block")) { | ||
| if ((!routesField || routesField.value.type !== "block") && (!surfaceRoutesField || surfaceRoutesField.value.type !== "block") && (!surfaceNavpointsField || surfaceNavpointsField.value.type !== "block")) { | ||
| return; | ||
@@ -226,49 +231,55 @@ } | ||
| const projectionType = symbolValue(getFieldValue(statement, "type")); | ||
| if (surfaceRoutesField?.value.type === "block" && !CONCRETE_UI_SURFACE_TYPES.has(projectionType || "")) { | ||
| pushError(errors, `Projection ${statement.id} routes belongs on concrete UI surfaces`, surfaceRoutesField.loc); | ||
| if (surfaceRoutesField?.value.type === "block") { | ||
| pushError(errors, `Projection ${statement.id} routes was renamed to navpoints. Use navpoints { navpoint nav_example }`, surfaceRoutesField.loc); | ||
| } | ||
| if (surfaceNavpointsField?.value.type === "block" && !CONCRETE_UI_SURFACE_TYPES.has(projectionType || "")) { | ||
| pushError(errors, `Projection ${statement.id} navpoints belongs on concrete UI surfaces`, surfaceNavpointsField.loc); | ||
| } | ||
| for (const entry of surfaceRoutesField?.value.type === "block" ? surfaceRoutesField.value.entries : []) { | ||
| for (const entry of surfaceNavpointsField?.value.type === "block" ? surfaceNavpointsField.value.entries : []) { | ||
| const tokens = blockSymbolItems(entry).map((item) => item.value); | ||
| const [keyword, routeId] = tokens; | ||
| if (keyword !== "route") { | ||
| pushError(errors, `Projection ${statement.id} routes entries must start with 'route'`, entry.loc); | ||
| const [keyword, navpointId] = tokens; | ||
| if (keyword !== "navpoint") { | ||
| pushError(errors, `Projection ${statement.id} navpoints entries must start with 'navpoint'`, entry.loc); | ||
| continue; | ||
| } | ||
| const route = registry.get(routeId); | ||
| if (!route) { | ||
| pushError(errors, `Projection ${statement.id} routes references missing route '${routeId}'`, entry.loc); | ||
| const navpoint = registry.get(navpointId); | ||
| if (!navpoint) { | ||
| pushError(errors, `Projection ${statement.id} navpoints references missing navpoint '${navpointId}'`, entry.loc); | ||
| continue; | ||
| } | ||
| if (route.kind !== "route") { | ||
| pushError(errors, `Projection ${statement.id} routes must reference route records, found ${route.kind} '${route.id}'`, entry.loc); | ||
| if (navpoint.kind !== "navpoint") { | ||
| pushError(errors, `Projection ${statement.id} navpoints must reference navpoint records, found ${navpoint.kind} '${navpoint.id}'`, entry.loc); | ||
| continue; | ||
| } | ||
| const directives = parseUiDirectiveMap(tokens, 2, errors, statement, entry, `routes for '${routeId}'`); | ||
| const directives = parseUiDirectiveMap(tokens, 2, errors, statement, entry, `navpoints for '${navpointId}'`); | ||
| for (const key of directives.keys()) { | ||
| if (!["path", "auth", "loader", "action"].includes(key)) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' has unknown directive '${key}'`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' has unknown directive '${key}'`, entry.loc); | ||
| } | ||
| } | ||
| const screenId = symbolValue(getFieldValue(route, "screen")); | ||
| const screenId = symbolValue(getFieldValue(navpoint, "screen")); | ||
| if (screenId && !availableScreens.has(screenId)) { | ||
| pushError(errors, `Projection ${statement.id} routes '${routeId}' references screen '${screenId}' that is not available to the surface`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navpoints '${navpointId}' references screen '${screenId}' that is not available to the surface`, entry.loc); | ||
| } | ||
| const canonicalPath = pathValue(getFieldValue(route, "path")); | ||
| const routePath = directives.get("path") || canonicalPath; | ||
| if (!routePath) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' must resolve a path`, entry.loc); | ||
| const canonicalPath = pathValue(getFieldValue(navpoint, "path")); | ||
| const navpointPath = directives.get("path") || canonicalPath; | ||
| if (!navpointPath) { | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' must resolve a path`, entry.loc); | ||
| continue; | ||
| } | ||
| if (CONCRETE_UI_SURFACE_TYPES.has(projectionType || "") && !routePath.startsWith("/")) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' must use an absolute path`, entry.loc); | ||
| if (CONCRETE_UI_SURFACE_TYPES.has(projectionType || "") && !navpointPath.startsWith("/")) { | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' must use an absolute path`, entry.loc); | ||
| } | ||
| const canonicalParams = routePathParams(canonicalPath); | ||
| const effectiveParams = routePathParams(routePath); | ||
| if (/^\/api(\/|$)/.test(navpointPath)) { | ||
| pushError(errors, `Projection ${statement.id} navpoint '${navpointId}' uses API-looking path '${navpointPath}'. Use endpoint for HTTP/API behavior.`, entry.loc); | ||
| } | ||
| const canonicalParams = pathParams(canonicalPath); | ||
| const effectiveParams = pathParams(navpointPath); | ||
| if (canonicalParams.join("|") !== effectiveParams.join("|")) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' path override must preserve params [${canonicalParams.join(", ")}]`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' path override must preserve params [${canonicalParams.join(", ")}]`, entry.loc); | ||
| } | ||
| const auth = directives.get("auth"); | ||
| if (auth && !ROUTE_AUTH_MODES.has(auth)) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' auth must be one of ${[...ROUTE_AUTH_MODES].join(", ")}`, entry.loc); | ||
| if (auth && !NAVPOINT_AUTH_MODES.has(auth)) { | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' auth must be one of ${[...NAVPOINT_AUTH_MODES].join(", ")}`, entry.loc); | ||
| } | ||
@@ -280,11 +291,11 @@ for (const [key, expectedKind] of [["loader", "capability"], ["action", "capability"]]) { | ||
| if (!target) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' references missing ${expectedKind} '${targetId}' for '${key}'`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' references missing ${expectedKind} '${targetId}' for '${key}'`, entry.loc); | ||
| } else if (target.kind !== expectedKind) { | ||
| pushError(errors, `Projection ${statement.id} routes for '${routeId}' must reference a ${expectedKind} for '${key}', found ${target.kind} '${target.id}'`, entry.loc); | ||
| pushError(errors, `Projection ${statement.id} navpoints for '${navpointId}' must reference a ${expectedKind} for '${key}', found ${target.kind} '${target.id}'`, entry.loc); | ||
| } | ||
| } | ||
| if (seenPaths.has(routePath)) { | ||
| pushError(errors, `Projection ${statement.id} routes has duplicate path '${routePath}'`, entry.loc); | ||
| if (seenPaths.has(navpointPath)) { | ||
| pushError(errors, `Projection ${statement.id} navpoints has duplicate path '${navpointPath}'`, entry.loc); | ||
| } | ||
| seenPaths.add(routePath); | ||
| seenPaths.add(navpointPath); | ||
| } | ||
@@ -325,4 +336,4 @@ | ||
| */ | ||
| function collectSurfaceRouteIds(statement, fieldMap) { | ||
| const routesField = fieldMap.get("routes")?.[0]; | ||
| function collectSurfaceNavpointIds(statement, fieldMap) { | ||
| const routesField = fieldMap.get("navpoints")?.[0]; | ||
| const ids = new Set(); | ||
@@ -334,3 +345,3 @@ if (!routesField || routesField.value.type !== "block") { | ||
| const tokens = blockSymbolItems(entry).map((item) => item.value); | ||
| if (tokens[0] === "route" && tokens[1]) { | ||
| if (tokens[0] === "navpoint" && tokens[1]) { | ||
| ids.add(tokens[1]); | ||
@@ -337,0 +348,0 @@ } |
@@ -227,5 +227,5 @@ // @ts-check | ||
| } | ||
| if (action === "navigate" && (!target || (target.kind !== "screen" && target.kind !== "route"))) { | ||
| if (action === "navigate" && (!target || (target.kind !== "screen" && target.kind !== "navpoint"))) { | ||
| pushError(errors, `Screen ${statement.id} renders event '${eventName}' references unknown navigation target '${targetId}'`, entry.loc); | ||
| } | ||
| } |
@@ -49,8 +49,3 @@ // @ts-check | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @returns {void} | ||
| */ | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */ | ||
| export function validateProjectionUiOwnership(errors, statement, fieldMap) { | ||
@@ -71,3 +66,3 @@ if (statement.kind !== "surface") { | ||
| errors, | ||
| `Surface ${statement.id} ${key} belongs on shared UI surfaces; concrete UI surfaces may define routes, screen_routes, navigation, and surface hints only`, | ||
| `Surface ${statement.id} ${key} belongs on shared UI surfaces; concrete UI surfaces may define navpoints, navigation, and surface hints only`, | ||
| field.loc | ||
@@ -85,5 +80,10 @@ ); | ||
| if (surfaceRoutesField?.value.type === "block" && !concreteUi) { | ||
| pushError(errors, `Surface ${statement.id} routes belongs on concrete UI surfaces`, surfaceRoutesField.loc); | ||
| pushError(errors, `Surface ${statement.id} routes was renamed to navpoints`, surfaceRoutesField.loc); | ||
| } | ||
| const surfaceNavpointsField = fieldMap.get("navpoints")?.[0]; | ||
| if (surfaceNavpointsField?.value.type === "block" && !concreteUi) { | ||
| pushError(errors, `Surface ${statement.id} navpoints belongs on concrete UI surfaces`, surfaceNavpointsField.loc); | ||
| } | ||
| const routesField = fieldMap.get("screen_routes")?.[0]; | ||
@@ -645,9 +645,3 @@ if (routesField?.value.type === "block" && !["web", "ios"].includes(projectionType || "")) { | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @returns {void} | ||
| */ | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */ | ||
| export function validateProjectionUiAppShell(errors, statement, fieldMap) { | ||
@@ -695,9 +689,3 @@ if (statement.kind !== "surface") { | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @returns {void} | ||
| */ | ||
| /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */ | ||
| export function validateProjectionUiDesign(errors, statement, fieldMap) { | ||
@@ -704,0 +692,0 @@ if (statement.kind !== "surface") { |
@@ -39,2 +39,4 @@ // @ts-check | ||
| pushError(errors, `Statement kind ${renameDiagnostic("'design_realization_set'", "'component_map'", "component_map map_product_widgets { ... }")}`, statement.loc); | ||
| } else if (statement.kind === "route") { | ||
| pushError(errors, "Statement kind 'route' was renamed. Use 'navpoint' for UI navigation or 'endpoint' for HTTP/API behavior.", statement.loc); | ||
| } else if (statement.kind === "operation") { | ||
@@ -41,0 +43,0 @@ pushError(errors, "Statement kind 'operation' was removed. Use capability, workflow, verification, or task records for work-map intent.", statement.loc); |
@@ -124,3 +124,3 @@ function asArray(value) { | ||
| * | ||
| * Widgets declare reusable behavior capabilities; projection widget_bindings | ||
| * Widgets declare reusable behavior capabilities; screen render entries | ||
| * bindings provide concrete data/event outcomes. This derived contract is the | ||
@@ -127,0 +127,0 @@ * normalized bridge agents and generators can use without inferring behavior |
| // @ts-check | ||
| import { | ||
| UI_SECTION_KINDS | ||
| } from "../kinds.js"; | ||
| import { | ||
| getFieldValue, | ||
| pushError, | ||
| symbolValue, | ||
| symbolValues | ||
| } from "../utils.js"; | ||
| export const ROUTE_AUTH_MODES = new Set(["public", "authenticated", "role_based", "admin"]); | ||
| /** | ||
| * @param {string | null | undefined} path | ||
| * @returns {string[]} | ||
| */ | ||
| export function routePathParams(path) { | ||
| if (!path) return []; | ||
| return [...path.matchAll(/(^|\/):([A-Za-z][A-Za-z0-9_]*)/g)].map((match) => match[2]); | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @param {TopogramRegistry} registry | ||
| * @returns {void} | ||
| */ | ||
| export function validateRoute(errors, statement, fieldMap, registry) { | ||
| if (statement.kind !== "route") { | ||
| return; | ||
| } | ||
| const path = pathValue(getFieldValue(statement, "path")); | ||
| if (path && !path.startsWith("/")) { | ||
| pushError(errors, `Route ${statement.id} path must be absolute`, fieldMap.get("path")?.[0]?.loc || statement.loc); | ||
| } | ||
| const declaredParams = symbolValues(getFieldValue(statement, "params")); | ||
| const pathParams = routePathParams(path); | ||
| if (declaredParams.join("|") !== pathParams.join("|")) { | ||
| pushError( | ||
| errors, | ||
| `Route ${statement.id} params must match path params [${pathParams.join(", ")}]`, | ||
| fieldMap.get("params")?.[0]?.loc || fieldMap.get("path")?.[0]?.loc || statement.loc | ||
| ); | ||
| } | ||
| validateRef(errors, statement, fieldMap, registry, "screen", "screen"); | ||
| const screenId = symbolValue(getFieldValue(statement, "screen")); | ||
| const screen = screenId ? registry.get(screenId) : null; | ||
| if (screen?.kind === "screen") { | ||
| if (!getFieldValue(screen, "layout")) { | ||
| pushError(errors, `Route ${statement.id} screen '${screenId}' must declare a layout`, fieldMap.get("screen")?.[0]?.loc || statement.loc); | ||
| } | ||
| const renders = getFieldValue(screen, "renders"); | ||
| if (!renders || renders.type !== "block" || renders.entries.length === 0) { | ||
| pushError(errors, `Route ${statement.id} screen '${screenId}' must declare at least one renders entry`, fieldMap.get("screen")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| validateRef(errors, statement, fieldMap, registry, "loader", "capability"); | ||
| validateRef(errors, statement, fieldMap, registry, "action", "capability"); | ||
| const auth = symbolValue(getFieldValue(statement, "auth")); | ||
| if (auth && !ROUTE_AUTH_MODES.has(auth)) { | ||
| pushError(errors, `Route ${statement.id} auth must be one of ${[...ROUTE_AUTH_MODES].join(", ")}`, fieldMap.get("auth")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @returns {void} | ||
| */ | ||
| export function validateSection(errors, statement, fieldMap) { | ||
| if (statement.kind !== "section") { | ||
| return; | ||
| } | ||
| const kind = symbolValue(getFieldValue(statement, "kind")); | ||
| if (kind && !UI_SECTION_KINDS.has(kind)) { | ||
| pushError(errors, `Section ${statement.id} has invalid kind '${kind}'`, fieldMap.get("kind")?.[0]?.loc || statement.loc); | ||
| } | ||
| } | ||
| /** | ||
| * @param {TopogramToken | null | undefined} token | ||
| * @returns {string | null} | ||
| */ | ||
| export function pathValue(token) { | ||
| return token && (token.type === "string" || token.type === "symbol") ? token.value : null; | ||
| } | ||
| /** | ||
| * @param {ValidationErrors} errors | ||
| * @param {TopogramStatement} statement | ||
| * @param {TopogramFieldMap} fieldMap | ||
| * @param {TopogramRegistry} registry | ||
| * @param {string} fieldName | ||
| * @param {string} expectedKind | ||
| * @returns {void} | ||
| */ | ||
| function validateRef(errors, statement, fieldMap, registry, fieldName, expectedKind) { | ||
| const id = symbolValue(getFieldValue(statement, fieldName)); | ||
| if (!id) return; | ||
| const target = registry.get(id); | ||
| if (!target) { | ||
| pushError(errors, `Route ${statement.id} references missing ${expectedKind} '${id}' for '${fieldName}'`, fieldMap.get(fieldName)?.[0]?.loc || statement.loc); | ||
| } else if (target.kind !== expectedKind) { | ||
| pushError(errors, `Route ${statement.id} must reference a ${expectedKind} for '${fieldName}', found ${target.kind} '${target.id}'`, fieldMap.get(fieldName)?.[0]?.loc || statement.loc); | ||
| } | ||
| } |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
4935948
11.12%729
6.89%120609
11.23%234
7.83%11
10%