diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf7c70..5a3e499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 — 2026-09-30 + +- **`.archify` files are YAML.** They are parsed as YAML and handed to the renderer as JSON. Older JSON `.archify` files still open, since JSON is valid YAML. +- **Clicking a `.archify` file opens the diagram:** the new **Archify Diagram** editor is the default for `*.archify`. **Show Source** opens the YAML beside it, and the diagram follows your edits. +- **YAML syntax errors** are reported in the Problems panel. Archify validation problems point at the matching YAML line. +- **Schema completion** for `.archify` works through the YAML extension (`redhat.vscode-yaml`), via `yamlValidation`. + ## 0.2.0 — 2026-09-30 - `.archify` files: Archify JSON diagram sources with their own extension. They open as JSON, preview with the same button and keys, report problems, and get completion from the schema of their `diagram_type`. diff --git a/README.md b/README.md index ef158d0..1b65cde 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Archify Diagram Viewer -View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Write a diagram in a `.archify` file and see the interactive diagram next to it, updated as you type. Open rendered Archify HTML files in a viewer tab instead of a browser. +View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Click a `.archify` file (YAML) and the interactive diagram opens straight away; open its source beside it and the diagram follows your edits. Rendered Archify HTML files open in a viewer tab instead of a browser. ![An Archify workflow diagram as shown by the preview](docs/preview.png) @@ -10,39 +10,42 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i 1. Create a file ending in `.archify`, for example `hello.archify`: - ```json - { - "schema_version": 1, - "diagram_type": "architecture", - "meta": { "title": "Hello Archify", "output": "hello.html" }, - "components": [ - { "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] }, - { "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] }, - { "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] } - ], - "connections": [ - { "from": "browser", "to": "api", "label": "HTTPS" }, - { "from": "api", "to": "db", "label": "SQL" } - ] - } + ```yaml + schema_version: 1 + diagram_type: architecture + meta: + title: Hello Archify + output: hello.html + components: + - { id: browser, type: frontend, label: Browser, pos: [40, 120], size: [140, 60] } + - { id: api, type: backend, label: API, pos: [260, 120], size: [140, 60] } + - { id: db, type: database, label: PostgreSQL, pos: [480, 120], size: [140, 60] } + connections: + - { from: browser, to: api, label: HTTPS } + - { from: api, to: db, label: SQL } ``` -2. Click the preview button in the editor title, or press Cmd/Ctrl+K V. +2. Click it in the Explorer: the diagram opens. +3. To edit, use **Show Source** in the editor title. The YAML opens beside the diagram, which re-renders as you type. The same file is in [examples/hello.archify](examples/hello.archify). ## The `.archify` format -A `.archify` file is an Archify JSON diagram source, the same JSON the Archify CLI renders. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`. VS Code treats `.archify` as JSON, so you get syntax highlighting, formatting, and completion and hover from the schema for that `diagram_type`. +A `.archify` file is an Archify diagram source written in **YAML**. It has the same fields as the JSON the Archify CLI renders, and plain JSON content still works, since JSON is valid YAML. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`. -Archify's own naming, `name.architecture.json`, `name.workflow.json` and so on, works too. +- **Opening:** the **Archify Diagram** editor is the default for `.archify`. Use *Reopen Editor With… → Text Editor* to edit the YAML full-screen. +- **Completion:** with the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (`redhat.vscode-yaml`) installed, `.archify` files get completion and hover from Archify's schemas. The schema follows `diagram_type`. + +Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so on, works too. Those open as text with a preview button, and get schema help from VS Code's built-in JSON support. ## Features -- **Live preview**: for `.archify` files, Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. +- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live. +- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. - **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. -- **Problems**: `archify validate` runs on the source and reports schema, layout and evidence problems at the matching JSON location. -- **Schema help**: completion and hover documentation come from Archify's own JSON schemas. In `.archify` files, the schema follows `diagram_type`. +- **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the source. +- **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. - **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*. diff --git a/examples/hello.archify b/examples/hello.archify index 5e8c705..1451ddb 100644 --- a/examples/hello.archify +++ b/examples/hello.archify @@ -1,17 +1,28 @@ -{ - "schema_version": 1, - "diagram_type": "architecture", - "meta": { - "title": "Hello Archify", - "output": "hello.html" - }, - "components": [ - { "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] }, - { "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] }, - { "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] } - ], - "connections": [ - { "from": "browser", "to": "api", "label": "HTTPS" }, - { "from": "api", "to": "db", "label": "SQL" } - ] -} +schema_version: 1 +diagram_type: architecture +meta: + title: Hello Archify + output: hello.html +components: + - id: browser + type: frontend + label: Browser + pos: [40, 120] + size: [140, 60] + - id: api + type: backend + label: API + pos: [260, 120] + size: [140, 60] + - id: db + type: database + label: PostgreSQL + pos: [480, 120] + size: [140, 60] +connections: + - from: browser + to: api + label: HTTPS + - from: api + to: db + label: SQL diff --git a/package-lock.json b/package-lock.json index 582418f..9127eba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "archify-vscode-ext", "version": "0.2.0", "license": "MIT", + "dependencies": { + "yaml": "^2.9.1" + }, "devDependencies": { "@types/node": "^22.0.0", "@types/vscode": "~1.100.0", @@ -4684,6 +4687,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", diff --git a/package.json b/package.json index a23a7d8..0043169 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "archify-vscode-ext", "displayName": "Archify Diagram Viewer", - "description": "Visualize Archify diagrams: preview .archify files (and *.architecture.json-style sources) live, and open rendered Archify HTML inside VS Code.", - "version": "0.2.0", + "description": "Visualize Archify diagrams: .archify files (YAML) open straight into the interactive diagram; also previews Archify JSON sources and rendered Archify HTML inside VS Code.", + "version": "0.3.0", "publisher": "root-at-skic", "license": "MIT", "icon": "media/icon.png", @@ -97,19 +97,19 @@ }, { "command": "archify.renderToHtml", - "when": "archify.isDiagramSource || archifyPreviewFocus" + "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'" }, { "command": "archify.openInBrowser", - "when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'" + "when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'" }, { "command": "archify.refreshPreview", - "when": "archifyPreviewFocus" + "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'" }, { "command": "archify.showSource", - "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'" + "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'" } ], "editor/title": [ @@ -130,7 +130,7 @@ }, { "command": "archify.showSource", - "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'", + "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'", "group": "navigation" }, { @@ -184,6 +184,16 @@ } ], "customEditors": [ + { + "viewType": "archify.diagram", + "displayName": "Archify Diagram", + "selector": [ + { + "filenamePattern": "*.archify" + } + ], + "priority": "default" + }, { "viewType": "archify.htmlViewer", "displayName": "Archify Diagram Viewer", @@ -196,10 +206,6 @@ } ], "jsonValidation": [ - { - "fileMatch": "*.archify", - "url": "./schemas/archify.schema.json" - }, { "fileMatch": "*.architecture.json", "url": "./schemas/architecture.schema.json" @@ -299,11 +305,17 @@ }, "languages": [ { - "id": "json", + "id": "yaml", "extensions": [ ".archify" ] } + ], + "yamlValidation": [ + { + "fileMatch": "*.archify", + "url": "./schemas/archify.schema.json" + } ] }, "scripts": { @@ -329,5 +341,8 @@ "bugs": { "url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues" }, - "homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme" + "homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme", + "dependencies": { + "yaml": "^2.9.1" + } } diff --git a/scripts/to-yaml.mjs b/scripts/to-yaml.mjs new file mode 100644 index 0000000..def8824 --- /dev/null +++ b/scripts/to-yaml.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Rewrites .archify files from JSON to YAML in place (JSON input is also +// valid YAML, so already-converted files are simply re-formatted). +// Usage: node scripts/to-yaml.mjs ... +import fs from 'node:fs'; +import { Document, isSeq, parse, visit } from 'yaml'; + +export function archifyYaml(value) { + const doc = new Document(value); + // Short all-scalar lists such as pos/size/viewBox stay on one line. + visit(doc, { + Seq(_, node) { + if (isSeq(node) && node.items.length <= 4 && node.items.every((item) => typeof item.value === 'number')) node.flow = true; + }, + }); + return doc.toString({ lineWidth: 0, flowCollectionPadding: false }); +} + +for (const file of process.argv.slice(2)) { + const value = parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, archifyYaml(value)); + console.log(`yaml: ${file}`); +} diff --git a/src/detect.ts b/src/detect.ts index ca46195..85ccfa9 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1,4 +1,5 @@ import { parse, ParseError } from 'jsonc-parser'; +import { parse as parseYaml } from 'yaml'; export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const; export type DiagramType = (typeof DIAGRAM_TYPES)[number]; @@ -44,24 +45,44 @@ export function diagramBaseName(fileName: string): string { export interface DiagramSource { type: DiagramType; diagram: Record; + /** Source syntax: `.archify` files are YAML, Archify's own sources JSON. */ + format: 'yaml' | 'json'; + /** JSON text for the Archify renderer, which only reads JSON. */ + json: string; +} + +/** Parses `.archify` (YAML) or JSON text; undefined when it does not parse. */ +export function parseDiagramText(text: string, fileName = ''): unknown { + if (isArchifyFile(fileName)) { + try { + // YAML 1.2 also accepts the JSON form of older .archify files. + return parseYaml(text, { prettyErrors: false, uniqueKeys: false }); + } catch { + return undefined; + } + } + const errors: ParseError[] = []; + return parse(text, errors, { allowTrailingComma: true }); } /** - * Parses JSON text and returns the diagram when it declares an Archify - * `diagram_type`. Falls back to the file name for documents that do not - * parse yet, so a half-typed source still counts as a diagram. + * Returns the diagram when the text declares an Archify `diagram_type`. + * Falls back to the file name for documents that do not parse yet, so a + * half-typed source still counts as a diagram. */ export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined { if (text.length > MAX_SNIFF_BYTES) return undefined; - const errors: ParseError[] = []; - const value = parse(text, errors, { allowTrailingComma: true }); - if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) { - return { type: value.diagram_type, diagram: value }; + const format = isArchifyFile(fileName) ? 'yaml' : 'json'; + const value = parseDiagramText(text, fileName) as Record | undefined; + const isObject = value !== null && typeof value === 'object' && !Array.isArray(value); + const json = (diagram: unknown) => (format === 'json' ? text : JSON.stringify(diagram ?? {}, null, 2)); + if (isObject && isDiagramType(value.diagram_type)) { + return { type: value.diagram_type, diagram: value, format, json: json(value) }; } const byName = typeFromFilename(fileName); if (byName) { - const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - return { type: byName, diagram }; + const diagram = isObject ? value : {}; + return { type: byName, diagram, format, json: json(diagram) }; } return undefined; } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 159b68d..7fa489b 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,7 +1,7 @@ import { parse, ParseError } from 'jsonc-parser'; import * as vscode from 'vscode'; -import { readDiagramSource } from './detect'; -import { rangeForPointer } from './jsonPointer'; +import { isArchifyFile, readDiagramSource } from './detect'; +import { rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer'; import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer'; import { evidenceMode, log, runtimeFor } from './runtime'; @@ -61,7 +61,7 @@ export class DiagnosticsController implements vscode.Disposable { } private schedule(doc: vscode.TextDocument, delay: number): void { - if (!JSON_LANGUAGES.has(doc.languageId)) return; + if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return; const key = doc.uri.toString(); clearTimeout(this.timers.get(key)); this.timers.set(key, setTimeout(() => void this.validate(doc), delay)); @@ -74,22 +74,48 @@ export class DiagnosticsController implements vscode.Disposable { this.sequence.set(key, seq); const text = doc.getText(); - const source = readDiagramSource(text, doc.fileName); - if (!source || !this.enabled(doc.uri) || doc.isClosed) { + if (!this.enabled(doc.uri) || doc.isClosed) { this.collection.delete(doc.uri); return; } - // Leave syntax errors to the JSON language service. - const syntax: ParseError[] = []; - parse(text, syntax, { allowTrailingComma: false }); - if (syntax.length) { + if (isArchifyFile(doc.fileName)) { + // Nothing else reports YAML syntax errors in .archify files, and a file + // that does not parse has no diagram to validate yet. + const problems = yamlSyntaxProblems(text); + if (problems.length) { + this.collection.set( + doc.uri, + problems.map((problem) => { + const diagnostic = new vscode.Diagnostic( + new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)), + `YAML: ${problem.message}`, + vscode.DiagnosticSeverity.Error, + ); + diagnostic.source = 'archify'; + return diagnostic; + }), + ); + return; + } + } + const source = readDiagramSource(text, doc.fileName); + if (!source) { this.collection.delete(doc.uri); return; } + if (source.format === 'json') { + // Leave JSON syntax errors to the JSON language service. + const syntax: ParseError[] = []; + parse(text, syntax, { allowTrailingComma: false }); + if (syntax.length) { + this.collection.delete(doc.uri); + return; + } + } try { const runtime = await runtimeFor(this.context, doc.uri); - const result = await validate(runtime, source.type, text); + const result = await validate(runtime, source.type, source.json); if (seq !== this.sequence.get(key) || doc.isClosed) return; if (result.crash) { @@ -107,7 +133,7 @@ export class DiagnosticsController implements vscode.Disposable { this.collection.set( doc.uri, result.diagnostics.map((item) => { - const offsets = rangeForPointer(text, item.subject?.path); + const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, item.subject?.path) : rangeForPointer(text, item.subject?.path); const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end)); const fix = item.supportedFixes?.[0]; const diagnostic = new vscode.Diagnostic( diff --git a/src/extension.ts b/src/extension.ts index 43fff4f..4346be3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,10 +1,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import * as vscode from 'vscode'; -import { diagramBaseName, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect'; +import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect'; import { DiagnosticsController } from './diagnostics'; import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer'; -import { PreviewManager } from './preview'; +import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview'; import { isEvidenceError, renderDiagram, renderToFile } from './renderer'; import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime'; @@ -36,7 +36,9 @@ export function activate(context: vscode.ExtensionContext): void { const updateContext = () => { const doc = vscode.window.activeTextEditor?.document; const diagram = Boolean( - doc && JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)), + doc && + (isArchifyFile(doc.fileName) || + (JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))), ); const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText())); void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram); @@ -64,6 +66,7 @@ export function activate(context: vscode.ExtensionContext): void { const uri = sourceUri(arg); if (!uri) return; if (isHtmlUri(uri)) return openHtmlViewer(uri); + if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE); previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active); }), @@ -71,6 +74,12 @@ export function activate(context: vscode.ExtensionContext): void { const uri = sourceUri(arg); if (!uri) return; if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside); + if (isArchifyFile(uri.path)) { + return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + } previews.show(uri, vscode.ViewColumn.Beside, true); }), @@ -83,6 +92,11 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand('archify.showSource', async () => { const preview = previews.activePreview; + if (preview?.isEditor) { + // The diagram is the editor; open the YAML text beside it. + await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside); + return; + } if (preview) { const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One; await vscode.window.showTextDocument(preview.uri, { viewColumn: column }); @@ -115,7 +129,7 @@ export function activate(context: vscode.ExtensionContext): void { const runtime = await runtimeFor(context, uri); let result = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' }, - () => renderToFile(runtime, { ...source, text }, target.fsPath), + () => renderToFile(runtime, source, target.fsPath), ); if (!result.ok && isEvidenceError(result.error)) { const choice = await vscode.window.showWarningMessage( @@ -123,7 +137,7 @@ export function activate(context: vscode.ExtensionContext): void { { modal: true, detail: result.error }, 'Render Without Source Links', ); - if (choice) result = await renderToFile(runtime, { ...source, text }, target.fsPath, true); + if (choice) result = await renderToFile(runtime, source, target.fsPath, true); else return; } if (!result.ok) { @@ -156,7 +170,7 @@ export function activate(context: vscode.ExtensionContext): void { const runtime = await runtimeFor(context, uri); const result = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' }, - () => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)), + () => renderDiagram(runtime, source, evidenceMode(uri)), ); if (!result.ok || !result.html) { vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error }); diff --git a/src/jsonPointer.ts b/src/jsonPointer.ts index 0dfb6a0..ce7fd17 100644 --- a/src/jsonPointer.ts +++ b/src/jsonPointer.ts @@ -1,4 +1,5 @@ import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser'; +import { isMap, isScalar, isSeq, parseDocument } from 'yaml'; export interface OffsetRange { start: number; @@ -37,3 +38,42 @@ export function rangeForPointer(text: string, pointer: string | undefined): Offs } return { start: node.offset, end: node.offset + node.length }; } + +/** The same mapping for YAML text (`.archify` files). */ +export function rangeForPointerYaml(text: string, pointer: string | undefined): OffsetRange { + const doc = parseDocument(text, { keepSourceTokens: false, uniqueKeys: false }); + let node: unknown = doc.contents; + let keyRange: [number, number] | undefined; + for (const segment of pointerSegments(pointer ?? '')) { + if (isMap(node)) { + const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment)); + if (!pair) break; + keyRange = isScalar(pair.key) && pair.key.range ? [pair.key.range[0], pair.key.range[1]] : undefined; + node = pair.value; + } else if (isSeq(node) && typeof segment === 'number' && segment < node.items.length) { + keyRange = undefined; + node = node.items[segment]; + } else { + break; + } + } + if ((isMap(node) || isSeq(node)) && keyRange) return { start: keyRange[0], end: keyRange[1] }; + const range = (node as { range?: [number, number, number] } | null)?.range; + if (!range) return { start: 0, end: 0 }; + if (isMap(node) || isSeq(node)) return { start: range[0], end: Math.min(range[0] + 1, range[1]) }; + return { start: range[0], end: range[1] }; +} + +export interface SyntaxProblem extends OffsetRange { + message: string; +} + +/** YAML syntax errors with their offsets (none for valid YAML). */ +export function yamlSyntaxProblems(text: string): SyntaxProblem[] { + const doc = parseDocument(text, { uniqueKeys: false }); + return doc.errors.map((error) => ({ + start: error.pos[0], + end: Math.max(error.pos[1], error.pos[0] + 1), + message: error.message.split('\n')[0], + })); +} diff --git a/src/preview.ts b/src/preview.ts index 96bd634..e6f61d9 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -7,6 +7,8 @@ import { handleWebviewMessage, WebviewMessage } from './webviewMessages'; import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml'; export const PREVIEW_VIEW_TYPE = 'archify.preview'; +/** Default editor for .archify files: the rendered diagram. */ +export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram'; interface PreviewState { uri: string; @@ -28,11 +30,15 @@ class DiagramPreview implements vscode.Disposable { readonly panel: vscode.WebviewPanel, readonly uri: vscode.Uri, state?: PreviewState, + /** True when this is the custom editor of the file, not a side preview. */ + readonly isEditor = false, ) { this.hash = state?.hash; panel.webview.options = { enableScripts: true, localResourceRoots: [] }; - panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg'); - panel.title = `Preview ${path.basename(uri.path)}`; + if (!isEditor) { + panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg'); + panel.title = `Preview ${path.basename(uri.path)}`; + } this.disposables.push( panel.onDidDispose(() => this.dispose()), @@ -102,7 +108,7 @@ class DiagramPreview implements vscode.Disposable { let result; try { const runtime = await runtimeFor(this.context, this.uri); - result = await renderDiagram(runtime, { ...source, text }, evidenceMode(this.uri)); + result = await renderDiagram(runtime, source, evidenceMode(this.uri)); } catch (error) { result = { ok: false, error: error instanceof Error ? error.message : String(error) }; } @@ -152,19 +158,30 @@ function firstLines(text: string | undefined, count = 6): string { return (text ?? '').split('\n').slice(0, count).join('\n'); } -export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSerializer { +export class PreviewManager + implements vscode.Disposable, vscode.WebviewPanelSerializer, vscode.CustomTextEditorProvider +{ + /** Side previews, one per source file. */ private readonly previews = new Map(); + /** Custom-editor tabs of .archify files (several per file are allowed). */ + private readonly editors = new Set(); private active: DiagramPreview | undefined; private readonly disposables: vscode.Disposable[] = []; constructor(private readonly context: vscode.ExtensionContext) { this.disposables.push( vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this), + vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, { + webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true }, + supportsMultipleEditorsPerDocument: true, + }), vscode.workspace.onDidChangeTextDocument((event) => { - const preview = this.previews.get(event.document.uri.toString()); - if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate(); + if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return; + for (const preview of this.forUri(event.document.uri)) preview.scheduleUpdate(); + }), + vscode.workspace.onDidSaveTextDocument((doc) => { + for (const preview of this.forUri(doc.uri)) void preview.update(); }), - vscode.workspace.onDidSaveTextDocument((doc) => void this.previews.get(doc.uri.toString())?.update()), vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration('archify')) this.refreshAll(); }), @@ -180,6 +197,17 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer return this.active; } + private forUri(uri: vscode.Uri): DiagramPreview[] { + const key = uri.toString(); + return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key); + } + + /** Opening a .archify file lands here: the diagram is the editor. */ + async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise { + panel.webview.options = { enableScripts: true, localResourceRoots: [] }; + this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true)); + } + show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void { const existing = this.previews.get(uri.toString()); if (existing) { @@ -205,8 +233,11 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer private track(preview: DiagramPreview): void { const key = preview.uri.toString(); - this.previews.get(key)?.panel.dispose(); - this.previews.set(key, preview); + if (preview.isEditor) this.editors.add(preview); + else { + this.previews.get(key)?.panel.dispose(); + this.previews.set(key, preview); + } const onViewState = () => { if (preview.panel.active) this.active = preview; else if (this.active === preview) this.active = undefined; @@ -215,6 +246,7 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer preview.panel.onDidChangeViewState(onViewState); onViewState(); preview.onDidDispose(() => { + this.editors.delete(preview); if (this.previews.get(key) === preview) this.previews.delete(key); if (this.active === preview) { this.active = undefined; @@ -224,12 +256,13 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer } refreshAll(force = false): void { - for (const preview of this.previews.values()) void preview.update(force); + for (const preview of [...this.previews.values(), ...this.editors]) void preview.update(force); } dispose(): void { for (const preview of this.previews.values()) preview.panel.dispose(); this.previews.clear(); + this.editors.clear(); for (const disposable of this.disposables.splice(0)) disposable.dispose(); } } diff --git a/src/renderer.ts b/src/renderer.ts index 64d703f..add4c06 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -144,7 +144,7 @@ async function renderOnce( */ export async function renderDiagram( options: RuntimeOptions, - source: DiagramSource & { text: string }, + source: DiagramSource, evidence: 'fallback' | 'strict', ): Promise { const withEvidence = hasSourceEvidence(source.type, source.diagram); @@ -156,7 +156,7 @@ export async function renderDiagram( evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.', }; } - const first = await renderOnce(options, source.type, source.text, withEvidence); + const first = await renderOnce(options, source.type, source.json, withEvidence); if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) { return first; } @@ -173,13 +173,13 @@ export async function renderDiagram( */ export async function renderToFile( options: RuntimeOptions, - source: DiagramSource & { text: string }, + source: DiagramSource, outputPath: string, stripEvidence = false, ): Promise { const text = stripEvidence ? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2) - : source.text; + : source.json; const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram); return withScratch(options, async (dir) => { const input = path.join(dir, `diagram.${source.type}.json`); diff --git a/test/detect.test.ts b/test/detect.test.ts index e161053..fd66f45 100644 --- a/test/detect.test.ts +++ b/test/detect.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect'; -import { pointerSegments, rangeForPointer } from '../src/jsonPointer'; +import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/jsonPointer'; test('reads diagram_type from content', () => { const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json'); @@ -58,4 +58,30 @@ test('.archify files are diagram sources typed by their content', async () => { assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture'); assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle'); assert.equal(readDiagramSource('{"title":"no type yet"}', 'run.archify'), undefined); + const yaml = readDiagramSource('schema_version: 1\ndiagram_type: sequence\nmeta:\n title: T\n', 'flow.archify'); + assert.equal(yaml?.type, 'sequence'); + assert.equal(yaml?.format, 'yaml'); + assert.deepEqual(JSON.parse(yaml!.json), { schema_version: 1, diagram_type: 'sequence', meta: { title: 'T' } }); + assert.equal(readDiagramSource('diagram_type: [unclosed', 'bad.archify'), undefined); +}); + +test('yaml pointer ranges match the json behaviour', () => { + const text = 'meta:\n title: x\ncomponents:\n - id: a\n label: A\n'; + const meta = rangeForPointerYaml(text, '/meta'); + assert.equal(text.slice(meta.start, meta.end), 'meta'); + const title = rangeForPointerYaml(text, '/meta/title'); + assert.equal(text.slice(title.start, title.end), 'x'); + const item = rangeForPointerYaml(text, '/components/0'); + assert.equal(text.slice(item.start, item.end), 'i', 'first character of the item'); + const label = rangeForPointerYaml(text, '/components/0/label'); + assert.equal(text.slice(label.start, label.end), 'A'); + const missing = rangeForPointerYaml(text, '/components/0/type'); + assert.equal(text.slice(missing.start, missing.end), 'i', 'falls back to the nearest existing ancestor'); +}); + +test('yaml syntax problems carry offsets', () => { + assert.deepEqual(yamlSyntaxProblems('a: 1\nb: 2\n'), []); + const problems = yamlSyntaxProblems('a: [1, 2\nb: 3\n'); + assert.ok(problems.length > 0); + assert.ok(problems[0].start >= 0 && problems[0].end > problems[0].start); }); diff --git a/test/fixtures/cache-miss.archify b/test/fixtures/cache-miss.archify index 4ef2fab..20e817c 100644 --- a/test/fixtures/cache-miss.archify +++ b/test/fixtures/cache-miss.archify @@ -1,77 +1,165 @@ -{ - "schema_version": 1, - "diagram_type": "sequence", - "meta": { - "title": "Cache Miss Request Sequence", - "output": "examples/sequence-cache-miss-request.html", - "viewBox": [1080, 580], - "column_fit": "spread", - "animation": "trace", - "quality_profile": "showcase" - }, - "participants": [ - { "id": "user", "type": "external", "label": "User", "sublabel": "browser session" }, - { "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" }, - { "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" }, - { "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" }, - { "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" }, - { "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" }, - { "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" } - ], - "segments": [ - { "from": 150, "to": 250, "label": "Request" }, - { "from": 260, "to": 370, "label": "Fallback" }, - { "from": 380, "to": 480, "label": "Response + trace" } - ], - "messages": [ - { "id": "open-page", "from": "user", "to": "web", "y": 160, "label": "open page", "variant": "default" }, - { "id": "dashboard-request", "from": "web", "to": "api", "y": 185, "label": "GET /dashboard", "variant": "emphasis" }, - { "id": "verify-jwt", "from": "api", "to": "auth", "y": 210, "label": "verify JWT", "variant": "security" }, - { "id": "auth-claims", "from": "auth", "to": "api", "y": 238, "label": "claims ok", "variant": "return" }, - { "id": "cache-read", "from": "api", "to": "redis", "y": 270, "label": "read cache", "variant": "default" }, - { "id": "cache-miss", "from": "redis", "to": "api", "y": 298, "label": "miss", "variant": "return" }, - { "id": "profile-query", "from": "api", "to": "db", "y": 330, "label": "query profile + metrics", "variant": "emphasis" }, - { "id": "profile-rows", "from": "db", "to": "api", "y": 358, "label": "rows", "variant": "return" }, - { "id": "cache-write", "from": "api", "to": "redis", "y": 390, "label": "set cache", "variant": "dashed" }, - { "id": "trace-emit", "from": "api", "to": "trace", "y": 418, "label": "emit trace", "variant": "dashed" }, - { "id": "dashboard-response", "from": "api", "to": "web", "y": 443, "label": "200 JSON", "variant": "return" }, - { "id": "page-render", "from": "web", "to": "user", "y": 468, "label": "render", "variant": "return" } - ], - "activations": [ - { "participant": "web", "from": 180, "to": 474, "type": "frontend" }, - { "participant": "api", "from": 185, "to": 450, "type": "backend" }, - { "participant": "auth", "from": 205, "to": 244, "type": "security" }, - { "participant": "redis", "from": 265, "to": 304, "type": "database" }, - { "participant": "db", "from": 325, "to": 364, "type": "database" }, - { "participant": "trace", "from": 413, "to": 449, "type": "messagebus" } - ], - "cards": [ - { - "dot": "emerald", - "title": "Happy Path", - "items": [ - "The main request is Web App -> API -> data source -> response", - "Return messages are quieter than forward calls", - "Activation bars make ownership duration visible" - ] - }, - { - "dot": "rose", - "title": "Policy + Fallback", - "items": [ - "JWT verification is colored as a security interaction", - "Cache miss is visible without overpowering the main path", - "Database access only appears after cache fallback" - ] - }, - { - "dot": "orange", - "title": "Async Trace", - "items": [ - "Trace emission is dashed and secondary", - "It does not block the response path", - "The diagram separates user-facing latency from observability" - ] - } - ] -} +schema_version: 1 +diagram_type: sequence +meta: + title: Cache Miss Request Sequence + output: examples/sequence-cache-miss-request.html + viewBox: [1080, 580] + column_fit: spread + animation: trace + quality_profile: showcase +participants: + - id: user + type: external + label: User + sublabel: browser session + - id: web + type: frontend + label: Web App + sublabel: React UI + - id: api + type: backend + label: API + sublabel: request handler + - id: auth + type: security + label: Auth + sublabel: JWT verify + - id: redis + type: database + label: Redis + sublabel: cache + - id: db + type: database + label: Postgres + sublabel: source of truth + - id: trace + type: messagebus + label: Trace + sublabel: async event +segments: + - from: 150 + to: 250 + label: Request + - from: 260 + to: 370 + label: Fallback + - from: 380 + to: 480 + label: Response + trace +messages: + - id: open-page + from: user + to: web + y: 160 + label: open page + variant: default + - id: dashboard-request + from: web + to: api + y: 185 + label: GET /dashboard + variant: emphasis + - id: verify-jwt + from: api + to: auth + y: 210 + label: verify JWT + variant: security + - id: auth-claims + from: auth + to: api + y: 238 + label: claims ok + variant: return + - id: cache-read + from: api + to: redis + y: 270 + label: read cache + variant: default + - id: cache-miss + from: redis + to: api + y: 298 + label: miss + variant: return + - id: profile-query + from: api + to: db + y: 330 + label: query profile + metrics + variant: emphasis + - id: profile-rows + from: db + to: api + y: 358 + label: rows + variant: return + - id: cache-write + from: api + to: redis + y: 390 + label: set cache + variant: dashed + - id: trace-emit + from: api + to: trace + y: 418 + label: emit trace + variant: dashed + - id: dashboard-response + from: api + to: web + y: 443 + label: 200 JSON + variant: return + - id: page-render + from: web + to: user + y: 468 + label: render + variant: return +activations: + - participant: web + from: 180 + to: 474 + type: frontend + - participant: api + from: 185 + to: 450 + type: backend + - participant: auth + from: 205 + to: 244 + type: security + - participant: redis + from: 265 + to: 304 + type: database + - participant: db + from: 325 + to: 364 + type: database + - participant: trace + from: 413 + to: 449 + type: messagebus +cards: + - dot: emerald + title: Happy Path + items: + - The main request is Web App -> API -> data source -> response + - Return messages are quieter than forward calls + - Activation bars make ownership duration visible + - dot: rose + title: Policy + Fallback + items: + - JWT verification is colored as a security interaction + - Cache miss is visible without overpowering the main path + - Database access only appears after cache fallback + - dot: orange + title: Async Trace + items: + - Trace emission is dashed and secondary + - It does not block the response path + - The diagram separates user-facing latency from observability diff --git a/test/fixtures/web-platform.archify b/test/fixtures/web-platform.archify index bbcf9bd..a7f576a 100644 --- a/test/fixtures/web-platform.archify +++ b/test/fixtures/web-platform.archify @@ -1,51 +1,212 @@ -{ - "schema_version": 1, - "diagram_type": "architecture", - "meta": { - "title": "Production Deployment Ownership", - "output": "examples/production-deployment.html", - "visual_preset": "blueprint", - "animation": "trace", - "quality_profile": "showcase", - "engineering_profile": "deployment-ownership" - }, - "components": [ - { "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] }, - { "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" }, - { "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" }, - { "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" }, - { "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" }, - { "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" }, - { "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" }, - { "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" }, - { "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1190, 300], "size": [126, 60], "tag": "app team" }, - { "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 578], "size": [126, 62], "tag": "data team" }, - { "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1190, 450], "size": [126, 62], "tag": "security" }, - { "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1190, 85], "size": [126, 62], "tag": "SRE" } - ], - "boundaries": [ - { "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"], "pad": 20 }, - { "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"], "pad": 14 }, - { "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] }, - { "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 } - ], - "connections": [ - { "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" }, - { "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" }, - { "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] }, - { "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] }, - { "from": "api_a", "to": "redis", "label": "cache", "route": "straight" }, - { "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" }, - { "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] }, - { "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] }, - { "from": "events", "to": "worker", "variant": "emphasis" }, - { "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] }, - { "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 }, - { "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" } - ], - "cards": [ - { "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] }, - { "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] }, - { "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] } - ] -} +schema_version: 1 +diagram_type: architecture +meta: + title: Production Deployment Ownership + output: examples/production-deployment.html + visual_preset: blueprint + animation: trace + quality_profile: showcase + engineering_profile: deployment-ownership +components: + - id: clients + type: external + label: Customers + sublabel: web + mobile + pos: [38, 300] + size: [122, 60] + - id: edge + type: cloud + label: Global Edge + sublabel: CDN + WAF + pos: [230, 300] + size: [126, 60] + tag: edge team + - id: gateway + type: security + label: API Gateway + sublabel: public :443 + pos: [430, 300] + size: [128, 60] + tag: platform + - id: api_a + type: backend + label: API Pods / AZ-a + sublabel: private subnet + pos: [630, 195] + size: [136, 62] + tag: app team + - id: api_b + type: backend + label: API Pods / AZ-b + sublabel: private subnet + pos: [630, 405] + size: [136, 62] + tag: app team + - id: redis + type: database + label: Redis + sublabel: multi-AZ cache + pos: [840, 195] + size: [126, 62] + tag: platform + - id: postgres + type: database + label: PostgreSQL + sublabel: primary / encrypted + pos: [840, 405] + size: [126, 62] + tag: data team + - id: events + type: messagebus + label: Event Bus + sublabel: orders.v1 + pos: [1040, 300] + size: [126, 60] + tag: platform + - id: worker + type: backend + label: Workers + sublabel: private workload + pos: [1190, 300] + size: [126, 60] + tag: app team + - id: replica + type: database + label: DR Replica + sublabel: eu-west-1 + pos: [1040, 578] + size: [126, 62] + tag: data team + - id: audit + type: cloud + label: Audit Archive + sublabel: immutable objects + pos: [1190, 450] + size: [126, 62] + tag: security + - id: observability + type: external + label: Observability + sublabel: metrics + traces + pos: [1190, 85] + size: [126, 62] + tag: SRE +boundaries: + - kind: region + label: AWS us-east-1 / production + wraps: + - edge + - gateway + - api_a + - api_b + - redis + - postgres + - events + - worker + - audit + pad: 20 + - kind: security-group + label: private application network + wraps: + - api_a + - api_b + - redis + - postgres + - events + - worker + pad: 14 + - kind: region + label: AWS eu-west-1 / disaster recovery + wraps: + - replica + - kind: security-group + label: DR private subnet + wraps: + - replica + pad: 14 +connections: + - from: clients + to: edge + label: HTTPS + variant: emphasis + - from: edge + to: gateway + label: mTLS + variant: security + - from: gateway + to: api_a + label: VPC route + variant: emphasis + route: orthogonal-h + labelAt: [594, 275] + - from: gateway + to: api_b + label: VPC route + variant: emphasis + route: orthogonal-h + labelAt: [594, 385] + - from: api_a + to: redis + label: cache + route: straight + - from: api_b + to: postgres + label: SQL + route: straight + - from: api_a + to: events + label: publish + variant: dashed + fromSide: top + toSide: top + via: + - [698, 170] + - [1103, 170] + - from: api_b + to: events + variant: dashed + fromSide: top + toSide: bottom + via: + - [698, 380] + - [1103, 380] + - from: events + to: worker + variant: emphasis + - from: postgres + to: replica + label: cross-region WAL + variant: security + route: orthogonal-v + labelAt: [1003, 529] + - from: worker + to: audit + label: evidence + variant: dashed + fromSide: bottom + toSide: top + labelDy: 58 + - from: worker + to: observability + label: OTLP + variant: dashed + route: orthogonal-v +cards: + - dot: cyan + title: Runtime Ownership + items: + - Platform owns the edge, gateway, cache, and event bus + - Application teams own API pods and workers + - Data owns primary and disaster-recovery state + - dot: rose + title: Named Crossings + items: + - Public HTTPS terminates at the managed edge + - mTLS crosses into the application network + - Cross-region WAL is explicit and encrypted + - dot: emerald + title: Operational Evidence + items: + - Workers emit traces to SRE-owned observability + - Audit evidence lands in immutable storage + - Unknown placement should remain marked, never invented diff --git a/test/integration/suite.ts b/test/integration/suite.ts index a520595..1a10ea1 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -49,36 +49,31 @@ export async function run(): Promise { await waitFor('activation', () => extension.isActive); }); - await check('.archify files open as JSON with the preview available', async () => { - const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify')); - assert.equal(doc.languageId, 'json'); - await vscode.window.showTextDocument(doc); - await vscode.commands.executeCommand('archify.showPreviewToSide'); - const tabs = await waitFor('.archify preview tab', () => { - const found = webviewTabs('archify.preview').filter((tab) => /web-platform\.archify/.test(tab.label)); - return found.length > 0 && found; - }); - assert.equal(tabs.length, 1); + await check('clicking a .archify file opens the diagram, not the text', async () => { + const uri = workspaceFile('web-platform.archify'); + await vscode.commands.executeCommand('vscode.open', uri); + const tab = await waitFor('diagram editor tab', () => + vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .find((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === uri.toString()), + ); + assert.ok(tab.isActive, 'the diagram tab is active'); + assert.equal(vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(), false, 'no text editor was opened for it'); }); - await check('.archify completion comes from the schema of its diagram_type', async () => { - const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify')); - await vscode.window.showTextDocument(doc); - // Complete the value of the first component's "type". - const offset = doc.getText().indexOf('"type": "') + '"type": "'.length; - const position = doc.positionAt(offset); - let labels: string[] = []; - for (let attempt = 0; attempt < 50 && !labels.includes('backend'); attempt += 1) { - const list = await vscode.commands.executeCommand('vscode.executeCompletionItemProvider', doc.uri, position); - labels = list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label).replace(/"/g, '')); - if (!labels.includes('backend')) await new Promise((resolve) => setTimeout(resolve, 300)); - } - assert.ok(labels.includes('backend') && labels.includes('database'), `offered: ${labels.join(', ')}`); + await check('.archify files are YAML and Show Source opens the text beside the diagram', async () => { + const uri = workspaceFile('web-platform.archify'); + await vscode.commands.executeCommand('archify.showSource'); + const editor = await waitFor('source text editor', () => + vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()), + ); + assert.equal(editor.document.languageId, 'yaml'); + assert.match(editor.document.getText(), /^schema_version: 1/m); }); - await check('.archify problems are reported against the file', async () => { + await check('.archify validation problems are reported against the YAML', async () => { const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify')); - const broken = good.getText().replace('"participants"', '"actors"'); + const broken = good.getText().replace(/^participants:/m, 'actors:'); const target = workspaceFile('broken.archify'); await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken)); const doc = await vscode.workspace.openTextDocument(target); @@ -90,6 +85,18 @@ export async function run(): Promise { assert.ok(diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error)); }); + await check('.archify YAML syntax errors are reported at their line', async () => { + const target = workspaceFile('syntax.archify'); + await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\ndiagram_type: architecture\nmeta: [unclosed\n')); + const doc = await vscode.workspace.openTextDocument(target); + await vscode.window.showTextDocument(doc); + const diagnostics = await waitFor('yaml syntax diagnostics', () => { + const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.message.startsWith('YAML:')); + return found.length > 0 && found; + }); + assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`); + }); + await check('opens a live preview beside the source', async () => { await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json')); const tabs = await waitFor('preview tab', () => { diff --git a/test/renderer.test.ts b/test/renderer.test.ts index a6d4bf9..95511be 100644 --- a/test/renderer.test.ts +++ b/test/renderer.test.ts @@ -24,7 +24,7 @@ function load(name: string) { const text = fs.readFileSync(path.join(fixtures, name), 'utf8'); const source = readDiagramSource(text, name); assert.ok(source, `${name} is a diagram source`); - return { ...source, text }; + return source; } for (const name of fs.readdirSync(fixtures).filter((file) => !file.startsWith('mco-'))) { @@ -46,7 +46,7 @@ test('reports schema problems with JSON pointer paths', async () => { test('render failure returns the CLI error', async () => { const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: {}, components: [] }); - const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), text }, 'fallback'); + const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), format: 'json', json: text }, 'fallback'); assert.equal(result.ok, false); assert.match(result.error ?? '', /schema validation failed/); }); diff --git a/test/schema.test.ts b/test/schema.test.ts index c2a8e50..9552e41 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import Ajv2020 from 'ajv/dist/2020'; +import { parse as parseYaml } from 'yaml'; const root = path.resolve(__dirname, '..', '..'); const schemas = path.join(root, 'schemas'); @@ -27,14 +28,14 @@ test('bundled schemas carry no absolute $id', () => { test('the .archify schema accepts every fixture', () => { const validate = validator(); for (const file of fs.readdirSync(fixtures)) { - const ok = validate(JSON.parse(fs.readFileSync(path.join(fixtures, file), 'utf8'))); + const ok = validate(parseYaml(fs.readFileSync(path.join(fixtures, file), 'utf8'))); assert.ok(ok, `${file}: ${JSON.stringify(validate.errors?.slice(0, 3))}`); } }); test('the .archify schema applies the schema of the declared type', () => { const validate = validator(); - const sequence = JSON.parse(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8')); + const sequence = parseYaml(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8')); // A sequence diagram is not a valid architecture diagram. assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), false); assert.equal(validate({ ...sequence, diagram_type: 'poster' }), false);