From 3b1d41250f5206704ac75ca91cdb23d86cf07162 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 21 Aug 2026 13:18:50 -0400 Subject: [PATCH] feat: add MCP concept write tools --- CHANGELOG.md | 9 +- README.md | 46 ++- lib/okf_io.dart | 2 +- lib/src/bundle_change_overlay.dart | 4 +- lib/src/cli.dart | 4 +- lib/src/mcp/{read_server.dart => server.dart} | 174 +++++++++- test/mcp_server_test.dart | 307 +++++++++++++++++- test/support.dart | 63 +++- 8 files changed, 547 insertions(+), 62 deletions(-) rename lib/src/mcp/{read_server.dart => server.dart} (50%) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd682bc..828f39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,17 @@ - Add composable graph filters for concept type, path prefix, and edge resolution. - Version and document the graph JSON schema. -- Add `okf mcp`, a read-only Model Context Protocol server over stdio with the +- Add `okf mcp`, a Model Context Protocol server over stdio with the `list-concepts`, `lookup-concept`, `query-graph`, and `validate` tools. Its `validate` tool returns the same Report and Verdict as the command line, and `query-graph` takes the graph filter vocabulary as its input schema. +- Add the `create-concept` and `update-concept` MCP write tools, thin adapters + over `OkfBundleChangeApplier`: one call writes the concept and maintains the + `index.md` and `log.md` entries atomically. A change the registered rules + reject is refused with the Report the command line prints for the same + state and leaves no file changed, while input that describes no bundle state + is a plain tool error. Updates manage `type`, `title`, `description`, + `tags`, and `body`, and retain every other frontmatter field. - Breaking: `okf validate --output json` replaces the `valid`, `error_count`, `warning_count`, and `diagnostics` fields with the Report projection — a `findings` array whose entries carry `id`, `severity`, `message`, and diff --git a/README.md b/README.md index 916ef00..5cc387f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ implementation and is not affiliated with or endorsed by Google. - Apply validated change sets that write concept, index, and log atomically. - Parse and emit `index.md` and `log.md` entries through one shared model. - Export bundle graphs as JSON, DOT, or Mermaid. -- Serve a read-only Model Context Protocol surface for coding agents. +- Serve a Model Context Protocol read/write surface for coding agents. - Use the APIs without `dart:io`, or import `okf_io.dart` for filesystem operations. @@ -105,11 +105,11 @@ action runs on Linux and macOS runners. ## MCP server -`okf mcp ` serves a read-only Model Context Protocol surface over -stdio, so a coding agent can navigate and check a bundle without raw file -reads. While the server runs, standard output carries JSON-RPC alone and -every diagnostic goes to standard error. Each call re-reads the bundle, so an -agent that edits files between calls never sees a stale answer. +`okf mcp ` serves a Model Context Protocol surface over stdio, so a +coding agent can navigate, check, and edit a bundle without raw file reads. +While the server runs, standard output carries JSON-RPC alone and every +diagnostic goes to standard error. Each call re-reads the bundle, so an agent +that edits files between calls never sees a stale answer. | Tool | Arguments | Returns | | --- | --- | --- | @@ -117,6 +117,8 @@ agent that edits files between calls never sees a stale answer. | `lookup-concept` | `id` | One concept, including its canonical Markdown. | | `query-graph` | `OkfGraphQuery.jsonSchema` | The versioned graph JSON that `okf graph --output json` emits. | | `validate` | `strict` | The Report `okf validate --output json` emits, plus the Verdict's `exit_code`. | +| `create-concept` | `id`, `type`, `title`, `description`, `tags`, `body` | The bundle-relative paths the write committed. | +| `update-concept` | `id`, `type`, `title`, `description`, `tags`, `body` | The bundle-relative paths the write committed. | `validate` returns the same Report as the command line for the same bundle and inputs — the same finding IDs, locations, and severities — and `strict` @@ -124,6 +126,31 @@ is the `--warnings-as-errors` flag, so an agent can reproduce the CI gate's judgment before pushing. Arguments are validated against each tool's schema; rejected arguments come back as a tool error. +### Writes + +`create-concept` and `update-concept` write through `OkfBundleChangeApplier`, +so one call prepares the concept document and its `index.md` and `log.md` +entries, commits them under the shared bundle lock, and rolls them back +together if an ordinary filesystem write fails. `id` is the bundle-relative +concept ID without the `.md` suffix; `type` is required when creating. + +`type`, `title`, `description`, `tags`, and `body` are the fields the verbs +manage. An update overlays only the arguments it is given and retains every +other field — `resource`, `verification`, `sources`, and anything else the +document carries keep their values and their order. + +A write is judged before it reaches disk, against the same rules +`okf validate` runs. Two outcomes are distinguished: + +- A change the rules reject is **refused**: the call fails with structured + content carrying the Report — the same finding IDs the command line prints + for that state — and not one file is changed. Only Spec errors refuse a + write; an advisory-only candidate remains conformant and can commit. +- Input that describes no bundle state is a plain **tool error**, with a + message and no Report: a malformed argument, an ID that is not + bundle-relative or that would occupy a reserved `index.md` or `log.md` path, + creating a concept that already exists, or updating one that does not. + Register the server with an MCP client by pointing it at the executable: ```json @@ -248,9 +275,10 @@ and Markdown content, but YAML comments, anchors, scalar quoting, and whitespace style are not retained. Filesystem link checks assume a quiescent bundle rather than a directory tree -being concurrently replaced by an adversarial process. Multi-file writes are -performed independently and do not preserve platform-specific ACLs or extended -attributes. +being concurrently replaced by an adversarial process. Prepared multi-file +writes are rollback-backed, not crash-atomic: destination files are replaced +independently, so a process or power failure can interrupt the transaction. +Writes do not preserve platform-specific ACLs or extended attributes. ## Scope diff --git a/lib/okf_io.dart b/lib/okf_io.dart index 009afe8..9e27fbd 100644 --- a/lib/okf_io.dart +++ b/lib/okf_io.dart @@ -5,4 +5,4 @@ export 'okf.dart'; export 'src/io/bundle_change_applier.dart'; export 'src/io/bundle_loader.dart'; export 'src/io/bundle_writer.dart' hide OkfBundleWriteTransaction; -export 'src/mcp/read_server.dart' show OkfMcpServer; +export 'src/mcp/server.dart' show OkfMcpServer; diff --git a/lib/src/bundle_change_overlay.dart b/lib/src/bundle_change_overlay.dart index 43946a6..da4a11f 100644 --- a/lib/src/bundle_change_overlay.dart +++ b/lib/src/bundle_change_overlay.dart @@ -40,6 +40,7 @@ final class OkfBundleChangeOverlay { final documents = LinkedHashMap.of( base.concepts, ); + final logs = Map.of(base.logFiles); final files = {}; final logEntries = []; final affectedIndexes = {}; @@ -63,10 +64,9 @@ final class OkfBundleChangeOverlay { } } - final logs = Map.of(base.logFiles); final log = logEntries.isEmpty ? null - : _rewrittenLog(base.logFiles[_rootLogPath], logEntries); + : _rewrittenLog(logs[_rootLogPath], logEntries); if (log != null) { logs[_rootLogPath] = log; files[_rootLogPath] = log; diff --git a/lib/src/cli.dart b/lib/src/cli.dart index de51ade..5c93f98 100644 --- a/lib/src/cli.dart +++ b/lib/src/cli.dart @@ -11,7 +11,7 @@ import 'graph.dart'; import 'index_generator.dart'; import 'io/bundle_loader.dart'; import 'io/bundle_writer.dart'; -import 'mcp/read_server.dart'; +import 'mcp/server.dart'; import 'spec_rules/load_findings.dart'; import 'validator.dart'; import 'version.dart'; @@ -355,7 +355,7 @@ Commands: format Canonically format Markdown documents index Generate deterministic bundle indexes graph Render the bundle relationship graph - mcp Serve the read tool surface over MCP stdio + mcp Serve the OKF tool surface over MCP stdio Global options: ${_parser.usage} diff --git a/lib/src/mcp/read_server.dart b/lib/src/mcp/server.dart similarity index 50% rename from lib/src/mcp/read_server.dart rename to lib/src/mcp/server.dart index 470de38..ea89cd6 100644 --- a/lib/src/mcp/read_server.dart +++ b/lib/src/mcp/server.dart @@ -4,29 +4,38 @@ import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; +import '../bundle_change_set.dart'; import '../concept_id.dart'; import '../document.dart'; import '../finding.dart'; import '../graph.dart'; +import '../io/bundle_change_applier.dart'; import '../io/bundle_loader.dart'; import '../version.dart'; -/// The read-only OKF tool surface served over the Model Context Protocol. +/// The OKF tool surface served over the Model Context Protocol. /// /// Every tool re-reads the bundle from disk, so an agent that edits files -/// between calls never observes a stale answer. +/// between calls never observes a stale answer. The write verbs are adapters +/// over [OkfBundleChangeApplier]: they translate tool arguments into a change +/// description and return its result, so validation, index and log +/// maintenance, and atomicity have one owner. final class OkfMcpServer { - /// Creates a server that answers questions about the bundle at [rootPath]. + /// Creates a server that reads and writes the bundle at [rootPath]. OkfMcpServer({ required this.rootPath, OkfBundleLoader loader = const OkfBundleLoader(), - }) : _loader = loader; + }) : _loader = loader, + _applier = const OkfBundleChangeApplier(); /// The bundle root every tool reads. final String rootPath; final OkfBundleLoader _loader; + /// Owns preparation and exact prepared commits for every write tool. + final OkfBundleChangeApplier _applier; + /// Serves the tool surface over stdio until the client disconnects. /// /// Standard output carries JSON-RPC alone, so every diagnostic goes to @@ -59,7 +68,12 @@ final class OkfMcpServer { capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); + _registerReadTools(server); + _registerWriteTools(server); + return server; + } + void _registerReadTools(McpServer server) { server.registerTool( 'list-concepts', description: 'List every concept in the bundle with its metadata.', @@ -82,12 +96,7 @@ final class OkfMcpServer { 'lookup-concept', description: 'Read one concept in its canonical Markdown form.', inputSchema: JsonSchema.object( - properties: { - 'id': JsonSchema.string( - minLength: 1, - description: 'Bundle-relative concept ID, without the .md suffix.', - ), - }, + properties: {'id': _conceptIdSchema}, required: const ['id'], additionalProperties: false, ), @@ -144,15 +153,59 @@ final class OkfMcpServer { }); }), ); + } - return server; + void _registerWriteTools(McpServer server) { + server.registerTool( + 'create-concept', + description: 'Create a concept, maintaining the index and log with it.', + inputSchema: _writeSchema(const ['id', 'type']), + annotations: const ToolAnnotations( + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + ), + callback: (arguments, extra) => _write( + () => OkfCreateConceptChange( + id: OkfConceptId(arguments['id']! as String), + document: OkfDocument( + frontmatter: _managedFrontmatter(arguments), + body: arguments['body'] as String? ?? '', + ), + ), + ), + ); + + server.registerTool( + 'update-concept', + description: 'Update the managed fields of an existing concept.', + inputSchema: _writeSchema(const ['id']), + annotations: const ToolAnnotations( + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + ), + callback: (arguments, extra) => _write( + () { + final frontmatter = _managedFrontmatter(arguments); + final body = arguments['body'] as String?; + if (frontmatter.isEmpty && body == null) { + throw OkfBundleChangeException( + 'Update for ${arguments['id']} does not change a managed field.', + ); + } + return OkfUpdateConceptChange( + id: OkfConceptId(arguments['id']! as String), + frontmatterChanges: frontmatter, + body: body, + ); + }, + ), + ); } - /// Answers one tool call from a freshly loaded bundle. - /// - /// Every failure below the protocol — an unreadable root, a rejected - /// argument, a missing concept — becomes a tool error, so a bad request - /// never ends the session. Future _readComplete( CallToolResult Function(OkfBundleLoadResult) answer, ) => @@ -168,9 +221,47 @@ final class OkfMcpServer { Future _readInspection( CallToolResult Function(OkfBundleLoadResult) answer, + ) => + _guard(() async => answer(await _loader.inspect(rootPath))); + + /// A Spec-invalid candidate comes back as a refusal carrying the report — + /// the finding IDs `okf validate` prints for the same state — and the bundle + /// is left exactly as it was. + /// + /// [describe] is a callback rather than a change so that translating the + /// tool arguments happens inside the guard below, keeping a rejected + /// argument on this server's tool-error path. + Future _write(OkfBundleChange Function() describe) => + _guard(() async { + final application = await _applier.apply( + rootPath, + OkfBundleChangeSet([describe()]), + ); + return switch (application) { + OkfBundleApplied(result: final result) => _payload( + {'changed_paths': result.changedPaths}, + ), + OkfBundleApplicationRefused(validation: final validation) => _error( + 'The change was refused; the bundle is unchanged.', + report: validation.report, + ), + }; + }); + + /// Runs one tool call, turning every failure below the protocol into a tool + /// error so a bad request never ends the session. + /// + /// This is the malformed-input tier that the static input schemas do not + /// already cover: an unreadable root, a rejected concept ID, or a change + /// that describes no bundle state at all. None of them carries a report, + /// which is what separates them from a refusal. + Future _guard( + Future Function() answer, ) async { try { - return answer(await _loader.inspect(rootPath)); + return await answer(); + } on OkfBundleChangeException catch (error) { + return _error(error.message); } on FormatException catch (error) { return _error(error.message); } on Exception catch (error) { @@ -186,6 +277,55 @@ const ToolAnnotations _readOnlyAnnotations = ToolAnnotations( openWorldHint: false, ); +/// The argument every tool that names a single concept takes, declared once so +/// the read and write verbs cannot describe the same ID differently. +final JsonSchema _conceptIdSchema = JsonSchema.string( + minLength: 1, + description: 'Bundle-relative concept ID, without the .md suffix.', +); + +/// The parameter shape both write verbs take. +/// +/// The properties beyond `id` are the concept fields the verbs manage; +/// everything else a document carries belongs to whoever wrote it. This is +/// also where the malformed-input tier is decided: an argument of the wrong +/// shape is rejected here, before any change is described. +JsonObject _writeSchema(List requiredProperties) => JsonSchema.object( + properties: { + 'id': _conceptIdSchema, + 'type': JsonSchema.string( + minLength: 1, + description: 'OKF concept type, such as Reference, Metric, or Note.', + ), + 'title': JsonSchema.string(minLength: 1, description: 'Display name.'), + 'description': JsonSchema.string(description: 'One-line summary.'), + 'tags': JsonSchema.array( + items: JsonSchema.string(minLength: 1), + description: 'Cross-cutting category tags.', + uniqueItems: true, + ), + 'body': JsonSchema.string( + description: 'Markdown body below the frontmatter.', + ), + }, + required: requiredProperties, + additionalProperties: false, + ); + +/// The managed frontmatter fields [arguments] carries. +/// +/// An absent field is left out rather than nulled, so an update never clears +/// what the caller did not mention. +Map _managedFrontmatter(Map arguments) => + { + if (arguments['type'] case final String type) 'type': type, + if (arguments['title'] case final String title) 'title': title, + if (arguments['description'] case final String description) + 'description': description, + if (arguments['tags'] case final List tags) + 'tags': tags.cast(), + }; + Map _conceptSummary(OkfConceptId id, OkfDocument document) { final type = document.type; final title = document.title; diff --git a/test/mcp_server_test.dart b/test/mcp_server_test.dart index a7c0055..6793d4e 100644 --- a/test/mcp_server_test.dart +++ b/test/mcp_server_test.dart @@ -37,7 +37,7 @@ void main() { return harness; } - test('advertises the fixed read tool surface', () async { + test('advertises the fixed tool surface', () async { await writeConcept(bundle, 'alpha.md'); final server = await serve(); @@ -50,10 +50,22 @@ void main() { expect( byName.keys.toSet(), - {'list-concepts', 'lookup-concept', 'query-graph', 'validate'}, + { + 'create-concept', + 'update-concept', + 'list-concepts', + 'lookup-concept', + 'query-graph', + 'validate', + }, ); - for (final tool in byName.values) { - expect(tool['annotations'], { + for (final tool in [ + 'list-concepts', + 'lookup-concept', + 'query-graph', + 'validate', + ]) { + expect(byName[tool]!['annotations'], { 'readOnlyHint': true, 'destructiveHint': false, 'idempotentHint': true, @@ -64,6 +76,18 @@ void main() { byName['query-graph']!['inputSchema'], OkfGraphQuery.jsonSchema, ); + expect(byName['create-concept']!['annotations'], { + 'readOnlyHint': false, + 'destructiveHint': false, + 'idempotentHint': false, + 'openWorldHint': false, + }); + expect(byName['update-concept']!['annotations'], { + 'readOnlyHint': false, + 'destructiveHint': true, + 'idempotentHint': true, + 'openWorldHint': false, + }); expect(await server.awaitDiagnostic(), contains('okf mcp: serving')); }); @@ -99,12 +123,11 @@ void main() { expect(payload['strict'], strict); } - final findings = ((await server.callTool('validate'))['structuredContent']! - as Map)['report']! as Map; expect( - (findings['findings']! as List) - .cast>() - .map((finding) => finding['id']), + _findingIds( + (await server.callTool('validate'))['structuredContent']! + as Map, + ), ['okf/invalid-status'], ); }); @@ -226,7 +249,7 @@ void main() { } final unknownTool = await server.send('tools/call', const { - 'name': 'create-concept', + 'name': 'link-concepts', 'arguments': {}, }); expect(unknownTool['error'], isNotNull); @@ -248,8 +271,15 @@ void main() { server.sendRaw('not json at all'); await bundle.delete(recursive: true); - final unreadable = await server.callTool('validate'); - expect(unreadable['isError'], isTrue); + for (final unreadable in >[ + await server.callTool('validate'), + await server.callTool('create-concept', const { + 'id': 'gamma', + 'type': 'Reference', + }), + ]) { + expect(unreadable['isError'], isTrue); + } await bundle.create(); await writeConcept(bundle, 'alpha.md'); @@ -267,6 +297,246 @@ void main() { expect(server.stdoutLines, everyElement(predicate(_isJsonRpc, 'JSON-RPC'))); }); + test('create-concept writes concept, index, and log in one operation', + () async { + await writeConcept( + bundle, + 'metrics/revenue.md', + type: 'Metric', + title: 'Revenue', + body: '# Revenue', + ); + final server = await serve(); + + final result = await server.call('create-concept', { + 'id': 'metrics/churn', + 'type': 'Metric', + 'title': 'Churn', + 'description': 'Monthly churn.', + 'tags': ['finance'], + 'body': '# Churn\n', + }); + + expect( + result['changed_paths'], + containsAll(['metrics/churn.md', 'metrics/index.md', 'log.md']), + ); + + final concept = + OkfDocument.parse(await readBundleFile(bundle, 'metrics/churn.md')); + expect(concept.type, 'Metric'); + expect(concept.title, 'Churn'); + expect(concept.description, 'Monthly churn.'); + expect(concept.tags, ['finance']); + expect(concept.body, '# Churn\n'); + + expect( + OkfIndexDocument.parse(await readBundleFile(bundle, 'metrics/index.md')) + .entries, + contains( + const OkfIndexEntry( + type: 'Metric', + title: 'Churn', + link: 'churn.md', + description: 'Monthly churn.', + ), + ), + ); + final log = OkfLogDocument.parse(await readBundleFile(bundle, 'log.md')); + expect(log.entries.single.action, 'Created'); + expect(log.entries.single.description, '[Churn](metrics/churn.md)'); + + final cli = await runCli(['validate', 'bundle'], sandbox.path); + expect( + cli.exitCode, + 0, + reason: 'the bundle the write path produced must pass the CLI gate', + ); + }); + + test('concurrent writes preserve every accepted change', () async { + await writeConcept( + bundle, + 'metrics/revenue.md', + type: 'Metric', + title: 'Revenue', + body: '# Revenue', + ); + final server = await serve(); + + await Future.wait(>>[ + server.call('create-concept', const { + 'id': 'metrics/churn', + 'type': 'Metric', + 'title': 'Churn', + }), + server.call('create-concept', const { + 'id': 'metrics/margin', + 'type': 'Metric', + 'title': 'Margin', + }), + ]); + + final index = OkfIndexDocument.parse( + await readBundleFile(bundle, 'metrics/index.md'), + ); + expect( + index.entries.map((entry) => entry.title), + containsAll(['Churn', 'Margin', 'Revenue']), + ); + final log = OkfLogDocument.parse(await readBundleFile(bundle, 'log.md')); + expect( + log.entries.map((entry) => entry.description), + containsAll([ + '[Churn](metrics/churn.md)', + '[Margin](metrics/margin.md)', + ]), + ); + }); + + test('commits advisory-only changes and separates malformed input', () async { + await writeConcept( + bundle, + 'metrics/revenue.md', + type: 'Metric', + title: 'Revenue', + body: '# Revenue', + ); + final server = await serve(); + final advisory = await server.call('create-concept', { + 'id': 'metrics/café', + 'type': 'Metric', + 'title': 'Café', + }); + expect(advisory['changed_paths'], contains('metrics/café.md')); + expect( + await File(p.join(bundle.path, 'metrics', 'café.md')).exists(), isTrue); + final validation = await server.call('validate'); + expect( + _findingIds(validation), + contains('okf/non-portable-concept-id'), + ); + final before = await snapshotBundle(bundle); + + final rejected = >[ + await server.callTool('create-concept', const { + 'id': 'metrics/churn', + }), + await server.callTool('create-concept', const { + 'id': 'metrics/churn', + 'type': 'Metric', + 'tags': 'finance', + }), + await server.callTool('update-concept', const { + 'id': 'metrics/revenue', + 'owner': 'finance-team', + }), + await server.callTool('update-concept', const { + 'id': 'metrics/revenue', + }), + await server.callTool('create-concept', const { + 'id': 'metrics/revenue', + 'type': 'Metric', + }), + await server.callTool('update-concept', const { + 'id': 'metrics/missing', + 'title': 'Missing', + }), + ]; + for (final error in rejected) { + expect(error['isError'], isTrue, reason: '${error['content']}'); + expect( + error['structuredContent'], + isNull, + reason: 'input that describes no bundle state carries no Report', + ); + } + expect(await snapshotBundle(bundle), before); + + final idempotent = await server.call( + 'update-concept', + const { + 'id': 'metrics/revenue', + 'title': 'Revenue', + }, + ); + expect(idempotent['changed_paths'], isEmpty); + expect(await snapshotBundle(bundle), before); + + final reserved = + await server.callTool('create-concept', const { + 'id': 'metrics/index', + 'type': 'Metric', + 'title': 'Reserved', + }); + expect(reserved['isError'], isTrue); + expect(reserved['structuredContent'], isNull); + expect(await snapshotBundle(bundle), before); + + await writeBundleFile( + bundle, + 'log.md', + '# Log\n\n* **Created**: [Revenue](metrics/revenue.md)\n', + ); + final broken = await snapshotBundle(bundle); + final refusedByLog = + await server.callTool('create-concept', const { + 'id': 'metrics/churn', + 'type': 'Metric', + 'title': 'Churn', + }); + expect( + _findingIds(refusedByLog['structuredContent']! as Map), + contains('okf/log-entry-before-date'), + reason: 'a log the write path cannot re-emit refuses the whole change', + ); + expect(await snapshotBundle(bundle), broken); + + expect(server.stdoutLines, everyElement(predicate(_isJsonRpc, 'JSON-RPC'))); + }); + + test('update-concept preserves fields the tool does not manage', () async { + await writeConcept( + bundle, + 'metrics/revenue.md', + type: 'Metric', + title: 'Revenue', + body: '# Revenue\n\nRecognized on delivery.', + frontmatter: const [ + 'description: Monthly revenue.', + 'owner: finance-team', + 'review:', + ' cadence: quarterly', + ], + ); + final server = await serve(); + + final result = await server.call('update-concept', { + 'id': 'metrics/revenue', + 'description': 'Recognized monthly revenue.', + }); + expect( + result['changed_paths'], + containsAll(['metrics/revenue.md', 'log.md']), + ); + + final document = OkfDocument.parse( + await readBundleFile(bundle, 'metrics/revenue.md'), + ); + expect(document.description, 'Recognized monthly revenue.'); + expect(document.type, 'Metric'); + expect(document.title, 'Revenue'); + expect(document.frontmatter['owner'], 'finance-team'); + expect( + document.frontmatter['review'], + {'cadence': 'quarterly'}, + ); + expect(document.body, '# Revenue\n\nRecognized on delivery.\n'); + + final log = OkfLogDocument.parse(await readBundleFile(bundle, 'log.md')); + expect(log.entries.single.action, 'Updated'); + }); + test('complete reads refuse a partial bundle while validate inspects it', () async { await writeConcept(bundle, 'alpha.md'); @@ -284,8 +554,10 @@ void main() { await server.callTool('query-graph'), ]) { expect(result['isError'], isTrue); - expect(jsonEncode(result['structuredContent']), - contains('invalid-document')); + expect( + jsonEncode(result['structuredContent']), + contains('invalid-document'), + ); } final validation = await server.callTool('validate'); @@ -298,6 +570,13 @@ void main() { }); } +List _findingIds(Map payload) => [ + for (final finding in ((payload['report']! + as Map)['findings']! as List) + .cast>()) + finding['id']! as String, + ]; + bool _isJsonRpc(Object? line) { final Object? decoded; try { diff --git a/test/support.dart b/test/support.dart index 4b0cb46..c511ef7 100644 --- a/test/support.dart +++ b/test/support.dart @@ -14,23 +14,54 @@ Future writeConcept( String title = 'Alpha', String body = '# Alpha', List frontmatter = const [], -}) async { - final file = File( - p.joinAll([root.path, ...p.posix.split(relativePath)]), - ); +}) => + writeBundleFile( + root, + relativePath, + [ + '---', + if (includeType) 'type: $type', + 'title: $title', + ...frontmatter, + '---', + '', + body, + '', + ].join('\n'), + ); + +/// Writes [content] at the bundle-relative [relativePath] under [root]. +Future writeBundleFile( + Directory root, + String relativePath, + String content, +) async { + final file = _bundleFile(root, relativePath); await file.parent.create(recursive: true); - await file.writeAsString( - [ - '---', - if (includeType) 'type: $type', - 'title: $title', - ...frontmatter, - '---', - '', - body, - '', - ].join('\n'), - ); + await file.writeAsString(content); +} + +/// Reads the bundle-relative [relativePath] under [root]. +Future readBundleFile(Directory root, String relativePath) => + _bundleFile(root, relativePath).readAsString(); + +/// Resolves a bundle-relative POSIX path against [root] for this platform. +File _bundleFile(Directory root, String relativePath) => + File(p.joinAll([root.path, ...p.posix.split(relativePath)])); + +/// Reads every file under [root], keyed by bundle-relative path. +/// +/// Comparing two snapshots is how a test proves a refused write left the +/// bundle untouched, rather than only checking the files it expected. +Future> snapshotBundle(Directory root) async { + final files = {}; + await for (final entity in root.list(recursive: true, followLinks: false)) { + if (entity is File) { + files[p.relative(entity.path, from: root.path)] = + await entity.readAsString(); + } + } + return files; } /// Runs the CLI in process and collects its streams.