From e7bb68c6db49367724507d6e31edd95cfa47e8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 16:06:17 +0000 Subject: [PATCH 1/3] Delete nine modules nothing reached A third of this repository served its own self-description rather than its function. This is the first cut: the modules no running surface referenced. Gone, with their tests: the DITA, DocBook, S1000D and iiRDS publishing adapters; the FAIR data principles; ASD-STE100 controlled language; the Easy-to-Read profile; ISO 4217 currency codes and the money type built on them. 2,266 lines. A terminal that records what an agent did does not need to emit aircraft maintenance manuals or know the minor-unit count of the Malawian kwacha. Two things survived the cut that a grep said were dead, which is why the check was "delete it and see what fails" rather than "count the references": ISO 3166 country codes are reached through `interop/language.zig`, which validates the region subtag in a tag like `en-GB`. That feeds `language/audience.zig` and so the wording of approval prompts. Deleting it would have quietly made `en-XX` a valid language tag. Money went further than the currency module. `reports/metrics.zig` carried a `currency_code` on every Metric, and `Metric.validate` checked the currency *instead of* the unit when one was set, so a money metric skipped unit validation entirely. No metric anywhere sets it. The field, that branch and the money formatting in `notation.zig` are gone, and validation is now one path for every metric. 606 tests pass, down from 612: the six removed all tested deleted code. `zag check` and `zag emit` still exit 0 and leave no generated change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- src/content/dita.zig | 343 ---------------------------- src/content/docbook.zig | 202 ----------------- src/content/iirds.zig | 148 ------------- src/content/s1000d.zig | 242 -------------------- src/data/fair.zig | 178 --------------- src/interop/currency.zig | 174 --------------- src/interop/iso4217_data.zig | 198 ----------------- src/language/controlled.zig | 406 ---------------------------------- src/language/easy_read.zig | 285 ------------------------ src/metadata/value_domain.zig | 31 --- src/reports/metrics.zig | 19 +- src/reports/notation.zig | 40 +--- src/root.zig | 8 - 13 files changed, 4 insertions(+), 2270 deletions(-) delete mode 100644 src/content/dita.zig delete mode 100644 src/content/docbook.zig delete mode 100644 src/content/iirds.zig delete mode 100644 src/content/s1000d.zig delete mode 100644 src/data/fair.zig delete mode 100644 src/interop/currency.zig delete mode 100644 src/interop/iso4217_data.zig delete mode 100644 src/language/controlled.zig delete mode 100644 src/language/easy_read.zig diff --git a/src/content/dita.zig b/src/content/dita.zig deleted file mode 100644 index 0fa2d8a..0000000 --- a/src/content/dita.zig +++ /dev/null @@ -1,343 +0,0 @@ -//! DITA publisher. -//! -//! The information type in the model chooses the DITA topic type, which is the -//! whole reason for typing content in the first place: a procedure becomes a -//! ``, an explanation becomes a ``, and everything else becomes -//! a ``. -//! -//! This is an export format, not the internal model. Nothing in the workbench -//! reads DITA back. - -const std = @import("std"); -const ir = @import("ir.zig"); -const timeutil = @import("../core/time.zig"); - -const escape = ir.writeXmlEscaped; - -pub const TopicType = enum { - task, - concept, - reference, - - pub fn forInformationType(information_type: ir.InformationType) TopicType { - return switch (information_type) { - .procedure => .task, - .concept, .principle, .process => .concept, - .fact, .structure, .classification => .reference, - }; - } - - pub fn rootElement(self: TopicType) []const u8 { - return @tagName(self); - } - - pub fn bodyElement(self: TopicType) []const u8 { - return switch (self) { - .task => "taskbody", - .concept => "conbody", - .reference => "refbody", - }; - } - - pub fn publicIdentifier(self: TopicType) []const u8 { - return switch (self) { - .task => "-//OASIS//DTD DITA Task//EN", - .concept => "-//OASIS//DTD DITA Concept//EN", - .reference => "-//OASIS//DTD DITA Reference//EN", - }; - } - - pub fn systemIdentifier(self: TopicType) []const u8 { - return switch (self) { - .task => "task.dtd", - .concept => "concept.dtd", - .reference => "reference.dtd", - }; - } -}; - -/// Write the document as a DITA map plus one topic for each top-level section. -pub fn writeMap(document: ir.Document, w: *std.Io.Writer) !void { - var id_buf: [@TypeOf(document.meta.id).text_len]u8 = undefined; - const id = document.meta.id.toText(&id_buf); - - try w.writeAll("\n"); - try w.writeAll("\n"); - try w.print("\n", .{ id, document.meta.lang }); - try w.writeAll(" "); - try escape(w, document.meta.title); - try w.writeAll("\n"); - try writeProlog(document.meta, w, " "); - - for (document.body, 0..) |node, index| { - switch (node) { - .section => |section| { - try w.print(" \n", .{ - id, - index, - TopicType.forInformationType(section.information_type).rootElement(), - }); - try w.writeAll(" "); - try escape(w, section.title); - try w.writeAll("\n \n"); - }, - else => {}, - } - } - try w.writeAll("\n"); -} - -/// Write one section as a DITA topic. -pub fn writeTopic(meta: ir.ArtifactMeta, section: ir.Node.Section, index: usize, w: *std.Io.Writer) !void { - const topic_type = TopicType.forInformationType(section.information_type); - var id_buf: [@TypeOf(meta.id).text_len]u8 = undefined; - const id = meta.id.toText(&id_buf); - - try w.writeAll("\n"); - try w.print("\n", .{ - topic_type.rootElement(), - topic_type.publicIdentifier(), - topic_type.systemIdentifier(), - }); - try w.print("<{s} id=\"{s}-{d}\" xml:lang=\"{f}\">\n", .{ topic_type.rootElement(), id, index, meta.lang }); - try w.writeAll(" "); - try escape(w, section.title); - try w.writeAll("\n"); - try writeProlog(meta, w, " "); - try w.print(" <{s}>\n", .{topic_type.bodyElement()}); - - for (section.children) |child| try writeNode(child, topic_type, w); - - try w.print(" \n", .{topic_type.bodyElement()}); - try w.print("\n", .{topic_type.rootElement()}); -} - -fn writeProlog(meta: ir.ArtifactMeta, w: *std.Io.Writer, indent: []const u8) !void { - var created: [timeutil.Timestamp.text_len_max]u8 = undefined; - var modified: [timeutil.Timestamp.text_len_max]u8 = undefined; - try w.print("{s}\n", .{indent}); - try w.print("{s} ", .{indent}); - try escape(w, meta.creator); - try w.writeAll("\n"); - try w.print("{s} \n", .{ - indent, - meta.created_at.toIso(&created, .second), - meta.modified_at.toIso(&modified, .second), - }); - try w.print("{s} \n", .{indent}); - for (meta.audience) |audience| { - try w.print("{s} \n"); - } - if (meta.concepts.len > 0 or meta.subjects.len > 0) { - try w.print("{s} \n", .{indent}); - for (meta.concepts) |concept| { - try w.print("{s} ", .{indent}); - try escape(w, concept); - try w.writeAll("\n"); - } - for (meta.subjects) |subject| { - try w.print("{s} ", .{indent}); - try escape(w, subject); - try w.writeAll("\n"); - } - try w.print("{s} \n", .{indent}); - } - try w.print("{s} \n", .{indent}); - try w.print("{s}\n", .{indent}); -} - -fn writeNode(node: ir.Node, topic_type: TopicType, w: *std.Io.Writer) !void { - switch (node) { - .paragraph => |text| { - try w.writeAll("

"); - try escape(w, text); - try w.writeAll("

\n"); - }, - .procedure => |procedure| { - try w.writeAll(" "); - try escape(w, procedure.outcome); - try w.writeAll("\n"); - if (procedure.prerequisites.len > 0) { - try w.writeAll("
    \n"); - for (procedure.prerequisites) |item| { - try w.writeAll("
  • "); - try escape(w, item); - try w.writeAll("
  • \n"); - } - try w.writeAll("
\n"); - } - try w.writeAll(" \n"); - for (procedure.steps) |step| { - try w.writeAll(" \n"); - if (step.admonition) |admonition| { - try w.print(" ", .{ditaNoteType(admonition.kind)}); - try escape(w, admonition.consequence); - if (admonition.action) |action| { - try w.writeAll(" "); - try escape(w, action); - } - try w.writeAll("\n"); - } - try w.writeAll(" "); - try escape(w, step.action); - try w.writeAll("\n"); - if (step.result) |result| { - try w.writeAll(" "); - try escape(w, result); - try w.writeAll("\n"); - } - try w.writeAll(" \n"); - } - try w.writeAll(" \n"); - }, - .admonition => |admonition| { - try w.print(" ", .{ditaNoteType(admonition.kind)}); - try escape(w, admonition.consequence); - try w.writeAll("\n"); - }, - .definition => |definition| { - try w.writeAll("
"); - try escape(w, definition.term); - try w.writeAll("
"); - try escape(w, definition.definition); - try w.writeAll("
\n"); - }, - .example => |example| { - try w.writeAll(" "); - if (example.title) |title| { - try w.writeAll(""); - try escape(w, title); - try w.writeAll(""); - } - try w.writeAll("

"); - try escape(w, example.body); - try w.writeAll("

\n"); - }, - .code => |code| { - try w.print(" ", .{code.language_name}); - try escape(w, code.code); - try w.writeAll("\n"); - }, - .list => |list| { - const tag = if (list.ordered) "ol" else "ul"; - try w.print(" <{s}>\n", .{tag}); - for (list.items) |item| { - try w.writeAll("
  • "); - try escape(w, item); - try w.writeAll("
  • \n"); - } - try w.print(" \n", .{tag}); - }, - .figure => |figure| { - try w.writeAll(" "); - try escape(w, figure.caption); - try w.print("", .{figure.source}); - try escape(w, figure.alternative_text); - try w.writeAll("\n"); - }, - .table => |table| { - try w.writeAll(" "); - try escape(w, table.caption); - try w.print("\n", .{table.headers.len}); - for (table.headers) |header| { - try w.writeAll(" "); - try escape(w, header); - try w.writeAll("\n"); - } - try w.writeAll(" \n"); - for (table.rows) |row| { - try w.writeAll(" "); - for (row) |cell| { - try w.writeAll(""); - try escape(w, cell); - try w.writeAll(""); - } - try w.writeAll("\n"); - } - try w.writeAll("
    \n"); - }, - .section => |inner| { - // A nested section becomes a section element inside the body. - try w.writeAll("
    "); - try escape(w, inner.title); - try w.writeAll("\n"); - for (inner.children) |child| try writeNode(child, topic_type, w); - try w.writeAll("
    \n"); - }, - .requirement => |requirement| { - try w.print("

    ", .{requirement.id}); - try escape(w, requirement.statement); - try w.writeAll("

    \n"); - }, - .decision => |decision| { - try w.print("

    ", .{decision.id}); - try escape(w, decision.decision); - try w.writeAll("

    \n"); - }, - .evidence => |evidence| { - try w.print("

    {s}: ", .{evidence.requirement_id}); - try escape(w, evidence.result); - try w.writeAll("

    \n"); - }, - } -} - -fn ditaNoteType(kind: ir.AdmonitionKind) []const u8 { - return switch (kind) { - .warning => "danger", - .caution => "caution", - .note => "note", - .prerequisite => "important", - }; -} - -const testing = std.testing; - -test "a procedure becomes a dita task, a concept becomes a dita concept" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const idmod = @import("../core/id.zig"); - var gen: idmod.Generator = .init(711, 1_788_000_000_000); - const document = try ir.sampleDocument(&gen); - - var map: std.Io.Writer.Allocating = .init(arena); - try writeMap(document, &map.writer); - try testing.expect(std.mem.indexOf(u8, map.written(), "") != null); - try testing.expect(std.mem.indexOf(u8, text, "Open the policy panel.") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); -} - -test "xml special characters are escaped" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const idmod = @import("../core/id.zig"); - var gen: idmod.Generator = .init(712, 1_788_000_000_000); - var document = try ir.sampleDocument(&gen); - document.body = &.{.{ .section = .{ - .title = "Angles & ampersands", - .information_type = .concept, - .children = &.{.{ .paragraph = "Use & \"quotes\" carefully." }}, - } }}; - - var topic: std.Io.Writer.Allocating = .init(arena); - try writeTopic(document.meta, document.body[0].section, 0, &topic.writer); - const text = topic.written(); - try testing.expect(std.mem.indexOf(u8, text, "<stdin> & "quotes"") != null); - try testing.expect(std.mem.indexOf(u8, text, "Angles & ampersands") != null); -} diff --git a/src/content/docbook.zig b/src/content/docbook.zig deleted file mode 100644 index a7e23d2..0000000 --- a/src/content/docbook.zig +++ /dev/null @@ -1,202 +0,0 @@ -//! DocBook 5.1 publisher. -//! -//! DocBook is the format used where a book-shaped deliverable is expected: a -//! manual, a printed guide, a specification. The mapping is direct, because -//! DocBook has an element for each of the model's block types. - -const std = @import("std"); -const ir = @import("ir.zig"); -const timeutil = @import("../core/time.zig"); - -const escape = ir.writeXmlEscaped; - -pub fn write(document: ir.Document, w: *std.Io.Writer) !void { - var id_buf: [@TypeOf(document.meta.id).text_len]u8 = undefined; - var created: [timeutil.Timestamp.text_len_max]u8 = undefined; - - try w.writeAll("\n"); - try w.print("
    \n", .{ - document.meta.lang, - document.meta.id.toText(&id_buf), - }); - try w.writeAll(" \n "); - try escape(w, document.meta.title); - try w.writeAll("\n"); - if (document.meta.summary) |summary| { - try w.writeAll(" "); - try escape(w, summary); - try w.writeAll("\n"); - } - try w.writeAll(" "); - try escape(w, document.meta.creator); - try w.writeAll("\n"); - try w.print(" {s}\n", .{document.meta.created_at.toIso(&created, .second)}); - if (document.meta.license) |license| { - try w.writeAll(" "); - try escape(w, license); - try w.writeAll("\n"); - } - for (document.meta.subjects) |subject| { - try w.writeAll(" "); - try escape(w, subject); - try w.writeAll("\n"); - } - try w.writeAll(" \n"); - - try w.writeAll(" "); - try escape(w, document.reader_outcome); - try w.writeAll("\n"); - - for (document.body) |node| try writeNode(node, w, " "); - try w.writeAll("
    \n"); -} - -fn writeNode(node: ir.Node, w: *std.Io.Writer, indent: []const u8) !void { - switch (node) { - .section => |section| { - try w.print("{s}
    \n{s} ", .{ indent, indent }); - try escape(w, section.title); - try w.writeAll("\n"); - for (section.children) |child| try writeNode(child, w, indent); - try w.print("{s}
    \n", .{indent}); - }, - .paragraph => |text| { - try w.print("{s} ", .{indent}); - try escape(w, text); - try w.writeAll("\n"); - }, - .procedure => |procedure| { - try w.print("{s} \n{s} ", .{ indent, indent }); - try escape(w, procedure.title); - try w.writeAll("\n"); - for (procedure.steps) |step| { - try w.print("{s} \n", .{indent}); - if (step.admonition) |admonition| try writeAdmonition(admonition, w, indent); - try w.print("{s} ", .{indent}); - try escape(w, step.action); - try w.writeAll("\n"); - if (step.result) |result| { - try w.print("{s} ", .{indent}); - try escape(w, result); - try w.writeAll("\n"); - } - try w.print("{s} \n", .{indent}); - } - try w.print("{s} \n", .{indent}); - }, - .admonition => |admonition| try writeAdmonition(admonition, w, indent), - .definition => |definition| { - try w.print("{s} ", .{indent}); - try escape(w, definition.term); - try w.writeAll(""); - try escape(w, definition.definition); - try w.writeAll("\n"); - }, - .example => |example| { - try w.print("{s} ", .{indent}); - if (example.title) |title| { - try w.writeAll(""); - try escape(w, title); - try w.writeAll(""); - } - try w.writeAll(""); - try escape(w, example.body); - try w.writeAll("\n"); - }, - .code => |code| { - try w.print("{s} ", .{ indent, code.language_name }); - try escape(w, code.code); - try w.writeAll("\n"); - }, - .list => |list| { - const tag = if (list.ordered) "orderedlist" else "itemizedlist"; - try w.print("{s} <{s}>\n", .{ indent, tag }); - for (list.items) |item| { - try w.print("{s} ", .{indent}); - try escape(w, item); - try w.writeAll("\n"); - } - try w.print("{s} \n", .{ indent, tag }); - }, - .figure => |figure| { - try w.print("{s}
    ", .{indent}); - try escape(w, figure.caption); - try w.print("", .{figure.source}); - try escape(w, figure.alternative_text); - try w.writeAll("
    \n"); - }, - .table => |table| { - try w.print("{s} ", .{indent}); - try escape(w, table.caption); - try w.print("", .{table.headers.len}); - for (table.headers) |header| { - try w.writeAll(""); - try escape(w, header); - try w.writeAll(""); - } - try w.writeAll(""); - for (table.rows) |row| { - try w.writeAll(""); - for (row) |cell| { - try w.writeAll(""); - try escape(w, cell); - try w.writeAll(""); - } - try w.writeAll(""); - } - try w.writeAll("
    \n"); - }, - .requirement => |requirement| { - try w.print("{s} ", .{ indent, requirement.id }); - try escape(w, requirement.statement); - try w.writeAll("\n"); - }, - .decision => |decision| { - try w.print("{s} ", .{ indent, decision.id }); - try escape(w, decision.decision); - try w.writeAll("\n"); - }, - .evidence => |evidence| { - try w.print("{s} ", .{indent}); - try escape(w, evidence.result); - try w.writeAll("\n"); - }, - } -} - -fn writeAdmonition(admonition: ir.Node.Admonition, w: *std.Io.Writer, indent: []const u8) !void { - const tag = switch (admonition.kind) { - .warning => "warning", - .caution => "caution", - .note => "note", - .prerequisite => "important", - }; - try w.print("{s} <{s}>", .{ indent, tag }); - try escape(w, admonition.consequence); - if (admonition.action) |action| { - try w.writeAll(" "); - try escape(w, action); - } - try w.print("\n", .{tag}); -} - -const testing = std.testing; - -test "docbook output carries the info block and the procedure" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const idmod = @import("../core/id.zig"); - var gen: idmod.Generator = .init(721, 1_788_000_000_000); - const document = try ir.sampleDocument(&gen); - - var aw: std.Io.Writer.Allocating = .init(arena); - try write(document, &aw.writer); - const text = aw.written(); - - try testing.expect(std.mem.indexOf(u8, text, "xmlns=\"http://docbook.org/ns/docbook\" version=\"5.1\"") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); - try testing.expect(std.mem.indexOf(u8, text, "MIT") != null); -} diff --git a/src/content/iirds.zig b/src/content/iirds.zig deleted file mode 100644 index 6f7f80a..0000000 --- a/src/content/iirds.zig +++ /dev/null @@ -1,148 +0,0 @@ -//! iiRDS publisher. -//! -//! iiRDS packages technical information with metadata that a delivery system -//! can act on: what the information is about, what kind it is, which product -//! and version it applies to. It is an RDF vocabulary, so the export reuses the -//! same graph writer as SKOS and PROV-O rather than inventing another one. - -const std = @import("std"); -const ir = @import("ir.zig"); -const rdf = @import("../knowledge/rdf.zig"); -const timeutil = @import("../core/time.zig"); - -pub const ns = "http://iirds.tekom.de/iirds#"; - -pub const InformationType = enum { - task, - concept, - reference, - troubleshooting, - - pub fn forInformationType(information_type: ir.InformationType) InformationType { - return switch (information_type) { - .procedure => .task, - .concept, .principle, .process => .concept, - .fact, .structure, .classification => .reference, - }; - } - - pub fn className(self: InformationType) []const u8 { - return switch (self) { - .task => "Task", - .concept => "Concept", - .reference => "Reference", - .troubleshooting => "Troubleshooting", - }; - } -}; - -pub const Package = struct { - /// Base IRI for the package's information units. - base: []const u8 = "https://zag.dev/iirds/", - product: []const u8 = "zag workbench", - product_version: []const u8 = "0.1.0", -}; - -/// Add a document to an RDF graph as iiRDS information units: one for the -/// document, one for each top-level section. -pub fn toGraph(graph: *rdf.Graph, document: ir.Document, package: Package) !void { - const arena = graph.arena; - const language_code = try arena.dupe(u8, document.meta.lang.languageCode()); - var id_buf: [@TypeOf(document.meta.id).text_len]u8 = undefined; - const id = document.meta.id.toText(&id_buf); - - const package_iri = try std.fmt.allocPrint(arena, "{s}package/{s}", .{ package.base, id }); - const unit_iri = try std.fmt.allocPrint(arena, "{s}unit/{s}", .{ package.base, id }); - const component_iri = try std.fmt.allocPrint(arena, "{s}component/{s}", .{ package.base, package.product_version }); - - try graph.addType(package_iri, ns ++ "Package"); - try graph.addIri(package_iri, ns ++ "has-info-unit", unit_iri); - try graph.addLiteral(package_iri, ns ++ "iiRDS-version", rdf.Literal.string("1.2")); - - try graph.addType(unit_iri, ns ++ "InformationUnit"); - try graph.addType(unit_iri, ns ++ "Topic"); - try graph.addLiteral(unit_iri, ns ++ "title", rdf.Literal.langString(document.meta.title, language_code)); - try graph.addLiteral(unit_iri, rdf.dcterms_ns ++ "language", rdf.Literal.string(language_code)); - try graph.addLiteral(unit_iri, rdf.dcterms_ns ++ "creator", rdf.Literal.string(document.meta.creator)); - - var created_buf: [timeutil.Timestamp.text_len_max]u8 = undefined; - try graph.addLiteral(unit_iri, rdf.dcterms_ns ++ "created", rdf.Literal.typed( - try arena.dupe(u8, document.meta.created_at.toIso(&created_buf, .second)), - rdf.xsd ++ "dateTime", - )); - if (document.meta.summary) |summary| { - try graph.addLiteral(unit_iri, rdf.dcterms_ns ++ "abstract", rdf.Literal.string(summary)); - } - if (document.meta.license) |license| { - try graph.addLiteral(unit_iri, rdf.dcterms_ns ++ "rights", rdf.Literal.string(license)); - } - - // The product this information applies to. - try graph.addType(component_iri, ns ++ "Component"); - try graph.addLiteral(component_iri, ns ++ "title", rdf.Literal.string(package.product)); - try graph.addLiteral(component_iri, ns ++ "has-product-version", rdf.Literal.string(package.product_version)); - try graph.addIri(unit_iri, ns ++ "relates-to-component", component_iri); - - // Subjects come from the workspace concept identifiers, so delivery systems - // filter on the same concepts the workbench reasons about. - for (document.meta.concepts) |concept| { - const concept_iri = try std.fmt.allocPrint(arena, "https://zag.dev/vocab/workspace/{s}", .{concept}); - try graph.addIri(unit_iri, ns ++ "has-subject", concept_iri); - } - for (document.meta.audience) |audience| { - const audience_iri = try std.fmt.allocPrint(arena, "{s}audience/{s}", .{ package.base, audience }); - try graph.addType(audience_iri, ns ++ "Party"); - try graph.addIri(unit_iri, ns ++ "is-applicable-for", audience_iri); - } - - for (document.body, 0..) |node, index| { - switch (node) { - .section => |section| { - const section_iri = try std.fmt.allocPrint(arena, "{s}unit/{s}-{d}", .{ package.base, id, index }); - const kind = InformationType.forInformationType(section.information_type); - try graph.addType(section_iri, ns ++ "InformationUnit"); - try graph.addType(section_iri, ns ++ "Topic"); - try graph.addIri(section_iri, ns ++ "has-information-type", try std.fmt.allocPrint(arena, "{s}{s}", .{ ns, kind.className() })); - try graph.addLiteral(section_iri, ns ++ "title", rdf.Literal.langString(section.title, language_code)); - try graph.addIri(unit_iri, rdf.dcterms_ns ++ "hasPart", section_iri); - try graph.addIri(package_iri, ns ++ "has-info-unit", section_iri); - }, - else => {}, - } - } -} - -pub fn writeTurtle(arena: std.mem.Allocator, document: ir.Document, package: Package, w: *std.Io.Writer) !void { - var graph = rdf.Graph.init(arena); - const prefixes = try arena.alloc(rdf.Prefix, rdf.default_prefixes.len + 1); - @memcpy(prefixes[0..rdf.default_prefixes.len], &rdf.default_prefixes); - prefixes[rdf.default_prefixes.len] = .{ .name = "iirds", .iri = ns }; - graph.prefixes = prefixes; - - try toGraph(&graph, document, package); - try graph.writeTurtle(w); -} - -const testing = std.testing; - -test "a document exports as an iirds package" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const idmod = @import("../core/id.zig"); - var gen: idmod.Generator = .init(741, 1_788_000_000_000); - const document = try ir.sampleDocument(&gen); - - var aw: std.Io.Writer.Allocating = .init(arena); - try writeTurtle(arena, document, .{}, &aw.writer); - const text = aw.written(); - - try testing.expect(std.mem.indexOf(u8, text, "@prefix iirds:") != null); - try testing.expect(std.mem.indexOf(u8, text, "a iirds:Package") != null); - try testing.expect(std.mem.indexOf(u8, text, "iirds:has-information-type iirds:Task") != null); - try testing.expect(std.mem.indexOf(u8, text, "iirds:has-information-type iirds:Concept") != null); - try testing.expect(std.mem.indexOf(u8, text, "iirds:relates-to-component") != null); - // Subjects point at the same concept identifiers the workbench uses. - try testing.expect(std.mem.indexOf(u8, text, "https://zag.dev/vocab/workspace/capability") != null); -} diff --git a/src/content/s1000d.zig b/src/content/s1000d.zig deleted file mode 100644 index 1b48910..0000000 --- a/src/content/s1000d.zig +++ /dev/null @@ -1,242 +0,0 @@ -//! S1000D data module publisher. -//! -//! S1000D exists where procedures must be traceable to a piece of equipment and -//! reused across publications. The workbench does not author in S1000D; it -//! exports into it, so that a team already working that way can take a -//! procedure without re-typing it. -//! -//! The data module code is built from the workspace identifiers, which keeps -//! exported modules stable across regenerations. - -const std = @import("std"); -const ir = @import("ir.zig"); -const timeutil = @import("../core/time.zig"); - -const escape = ir.writeXmlEscaped; - -/// The parts of a data module code the exporter needs. Real projects assign -/// these from their own breakdown; the caller supplies them. -pub const DataModuleCode = struct { - model_identification: []const u8 = "ZAG", - system_difference: []const u8 = "A", - system: []const u8 = "00", - subsystem: []const u8 = "0", - sub_subsystem: []const u8 = "0", - assembly: []const u8 = "00", - disassembly: []const u8 = "00", - disassembly_variant: []const u8 = "A", - information_code: []const u8 = "040", - information_code_variant: []const u8 = "A", - item_location: []const u8 = "D", - - /// `040` is a description; `520` is a procedure. Choosing the information - /// code from the content type is the point of typing the content. - pub fn forInformationType(information_type: ir.InformationType) DataModuleCode { - var code: DataModuleCode = .{}; - code.information_code = switch (information_type) { - .procedure => "520", - .process => "041", - .concept, .principle => "040", - .fact, .structure, .classification => "042", - }; - return code; - } - - pub fn write(self: DataModuleCode, w: *std.Io.Writer) std.Io.Writer.Error!void { - try w.print( - "", - .{ - self.model_identification, self.system_difference, self.system, - self.subsystem, self.sub_subsystem, self.assembly, - self.disassembly, self.disassembly_variant, self.information_code, - self.information_code_variant, self.item_location, - }, - ); - } -}; - -pub const Options = struct { - code: ?DataModuleCode = null, - security_classification: []const u8 = "01", - responsible_partner: []const u8 = "ZAG", - originator: []const u8 = "ZAG", -}; - -/// Write one section as an S1000D data module. -pub fn writeDataModule( - meta: ir.ArtifactMeta, - section: ir.Node.Section, - options: Options, - w: *std.Io.Writer, -) !void { - const code = options.code orelse DataModuleCode.forInformationType(section.information_type); - const is_procedure = section.information_type == .procedure; - - var issue_buf: [timeutil.Timestamp.text_len_max]u8 = undefined; - const issue_date = meta.modified_at.toIso(&issue_buf, .second); - - try w.writeAll("\n"); - try w.writeAll("\n \n \n \n "); - try code.write(w); - try w.print( - "\n \n", - .{ - if (meta.lang.region) |*region| region[0..] else "US", - &meta.lang.language, - }, - ); - try w.writeAll(" \n \n \n"); - try w.print(" \n", .{ - issue_date[0..4], - issue_date[5..7], - issue_date[8..10], - }); - try w.writeAll(" "); - try escape(w, meta.title); - try w.writeAll(""); - try escape(w, section.title); - try w.writeAll("\n \n \n"); - try w.print(" \n \n", .{options.security_classification}); - try w.print(" {s}\n", .{options.responsible_partner}); - try w.print(" {s}\n", .{options.originator}); - try w.writeAll(" All workspaces.\n"); - try w.writeAll(" \n \n \n"); - - try w.writeAll(" \n"); - if (is_procedure) { - try writeProceduralContent(section, w); - } else { - try writeDescriptiveContent(section, w); - } - try w.writeAll(" \n\n"); -} - -fn writeProceduralContent(section: ir.Node.Section, w: *std.Io.Writer) !void { - try w.writeAll(" \n \n"); - var wrote_prerequisite = false; - for (section.children) |child| { - switch (child) { - .procedure => |procedure| { - for (procedure.prerequisites) |item| { - if (!wrote_prerequisite) { - try w.writeAll(" \n"); - wrote_prerequisite = true; - } - try w.writeAll(" "); - try escape(w, item); - try w.writeAll("\n"); - } - }, - else => {}, - } - } - if (wrote_prerequisite) try w.writeAll(" \n"); - try w.writeAll(" \n \n"); - - for (section.children) |child| { - switch (child) { - .procedure => |procedure| { - for (procedure.steps) |step| { - try w.writeAll(" \n"); - if (step.admonition) |admonition| { - const tag = switch (admonition.kind) { - .warning => "warning", - .caution => "caution", - else => "note", - }; - try w.print(" <{s}><{s}Para>", .{ tag, tag }); - try escape(w, admonition.consequence); - if (admonition.action) |action| { - try w.writeAll(" "); - try escape(w, action); - } - try w.print("\n", .{ tag, tag }); - } - try w.writeAll(" "); - try escape(w, step.action); - try w.writeAll("\n"); - if (step.result) |result| { - try w.writeAll(" "); - try escape(w, result); - try w.writeAll("\n"); - } - try w.writeAll(" \n"); - } - }, - else => {}, - } - } - try w.writeAll(" \n \n \n"); -} - -fn writeDescriptiveContent(section: ir.Node.Section, w: *std.Io.Writer) !void { - try w.writeAll(" \n \n "); - try escape(w, section.title); - try w.writeAll("\n"); - for (section.children) |child| { - switch (child) { - .paragraph => |text| { - try w.writeAll(" "); - try escape(w, text); - try w.writeAll("\n"); - }, - .definition => |definition| { - try w.writeAll(" "); - try escape(w, definition.term); - try w.writeAll(" "); - try escape(w, definition.definition); - try w.writeAll("\n"); - }, - .list => |list| { - try w.print(" <{s}>\n", .{if (list.ordered) "sequentialList" else "randomList"}); - for (list.items) |item| { - try w.writeAll(" "); - try escape(w, item); - try w.writeAll("\n"); - } - try w.print(" \n", .{if (list.ordered) "sequentialList" else "randomList"}); - }, - else => {}, - } - } - try w.writeAll(" \n \n"); -} - -const testing = std.testing; - -test "a procedure exports as a procedural data module" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const idmod = @import("../core/id.zig"); - var gen: idmod.Generator = .init(731, 1_788_000_000_000); - const document = try ir.sampleDocument(&gen); - - var aw: std.Io.Writer.Allocating = .init(arena); - try writeDataModule(document.meta, document.body[1].section, .{}, &aw.writer); - const text = aw.written(); - - try testing.expect(std.mem.indexOf(u8, text, "infoCode=\"520\"") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); -} - -test "a concept exports as a descriptive data module" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const idmod = @import("../core/id.zig"); - var gen: idmod.Generator = .init(732, 1_788_000_000_000); - const document = try ir.sampleDocument(&gen); - - var aw: std.Io.Writer.Allocating = .init(arena); - try writeDataModule(document.meta, document.body[0].section, .{}, &aw.writer); - const text = aw.written(); - - try testing.expect(std.mem.indexOf(u8, text, "infoCode=\"040\"") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); - try testing.expect(std.mem.indexOf(u8, text, "") != null); -} diff --git a/src/data/fair.zig b/src/data/fair.zig deleted file mode 100644 index b30e4e7..0000000 --- a/src/data/fair.zig +++ /dev/null @@ -1,178 +0,0 @@ -//! FAIR assessment: findable, accessible, interoperable, reusable. -//! -//! FAIR is a set of principles, not a certification, so this module reports -//! which principles an artefact meets and which it does not, with the reason. -//! The point is to make the gaps visible: an artefact that is findable and -//! accessible but carries no licence is not reusable, and saying so is more -//! useful than a percentage. - -const std = @import("std"); - -pub const Principle = enum { - /// F1: a globally unique, persistent identifier. - f1_identifier, - /// F2: rich metadata. - f2_metadata, - /// F3: the metadata says which artefact it describes. - f3_metadata_links_data, - /// F4: it is registered or indexed somewhere searchable. - f4_indexed, - /// A1: retrievable by its identifier over an open protocol. - a1_retrievable, - /// A2: the metadata survives even if the data does not. - a2_metadata_persists, - /// I1: a formal, shared, broadly applicable representation. - i1_formal_representation, - /// I2: vocabularies that themselves follow FAIR. - i2_fair_vocabularies, - /// I3: qualified references to other artefacts. - i3_qualified_references, - /// R1: a plurality of accurate, relevant attributes. - r1_rich_attributes, - /// R1.1: a clear, accessible usage licence. - r1_1_licence, - /// R1.2: detailed provenance. - r1_2_provenance, - /// R1.3: it meets the standards of its domain. - r1_3_domain_standards, - - pub fn letter(self: Principle) u8 { - return switch (self) { - .f1_identifier, .f2_metadata, .f3_metadata_links_data, .f4_indexed => 'F', - .a1_retrievable, .a2_metadata_persists => 'A', - .i1_formal_representation, .i2_fair_vocabularies, .i3_qualified_references => 'I', - else => 'R', - }; - } - - pub fn question(self: Principle) []const u8 { - return switch (self) { - .f1_identifier => "Does it have an identifier that will not change?", - .f2_metadata => "Is it described well enough to be found?", - .f3_metadata_links_data => "Does the description say which thing it describes?", - .f4_indexed => "Is it listed somewhere a person or program can search?", - .a1_retrievable => "Can it be fetched by its identifier, using an open protocol?", - .a2_metadata_persists => "Does the description survive if the data is deleted?", - .i1_formal_representation => "Is it written in a shared, formal representation?", - .i2_fair_vocabularies => "Do its terms come from vocabularies that are themselves findable?", - .i3_qualified_references => "Does it say how it relates to other things, not only that it does?", - .r1_rich_attributes => "Does it carry enough attributes to be reused?", - .r1_1_licence => "Is there a licence that says what may be done with it?", - .r1_2_provenance => "Does it say where it came from?", - .r1_3_domain_standards => "Does it meet the standards its field expects?", - }; - } -}; - -pub const Answer = struct { - principle: Principle, - met: bool, - /// Evidence when met; what is missing when not. - note: []const u8, -}; - -pub const Assessment = struct { - subject: []const u8, - answers: []const Answer, - - pub fn metCount(self: Assessment) usize { - var count: usize = 0; - for (self.answers) |answer| { - if (answer.met) count += 1; - } - return count; - } - - pub fn unmet(self: Assessment, arena: std.mem.Allocator) !std.ArrayList(Answer) { - var out: std.ArrayList(Answer) = .empty; - for (self.answers) |answer| { - if (!answer.met) try out.append(arena, answer); - } - return out; - } - - /// Is one letter of FAIR fully met? - pub fn letterMet(self: Assessment, letter: u8) bool { - var seen = false; - for (self.answers) |answer| { - if (answer.principle.letter() != letter) continue; - seen = true; - if (!answer.met) return false; - } - return seen; - } - - pub fn writeReport(self: Assessment, w: *std.Io.Writer) std.Io.Writer.Error!void { - try w.print("FAIR assessment: {s}\n\n", .{self.subject}); - for (self.answers) |answer| { - try w.print("[{c}] {s} {s}\n {s}\n", .{ - answer.principle.letter(), - if (answer.met) "yes" else "no ", - answer.principle.question(), - answer.note, - }); - } - try w.print("\n{d} of {d} principles met.\n", .{ self.metCount(), self.answers.len }); - try w.writeAll("FAIR is a set of principles, not a certification. This report says which are met and which are not.\n"); - } -}; - -/// The assessment for the workspace vocabulary the product publishes. -pub fn vocabularyAssessment() Assessment { - return .{ - .subject = "the zag workspace vocabulary", - .answers = &.{ - .{ .principle = .f1_identifier, .met = true, .note = "Every concept has a stable IRI under https://zag.dev/vocab/workspace/." }, - .{ .principle = .f2_metadata, .met = true, .note = "Each concept carries a preferred term, a definition, a subject field and its relations." }, - .{ .principle = .f3_metadata_links_data, .met = true, .note = "Each concept is linked to its scheme with skos:inScheme." }, - .{ .principle = .f4_indexed, .met = false, .note = "The vocabulary is published in the repository but is not registered in any external catalogue yet." }, - .{ .principle = .a1_retrievable, .met = true, .note = "It is served as a file over HTTPS, and is in the repository." }, - .{ .principle = .a2_metadata_persists, .met = true, .note = "The vocabulary is the metadata; it is version-controlled." }, - .{ .principle = .i1_formal_representation, .met = true, .note = "It is published as SKOS in Turtle and JSON-LD." }, - .{ .principle = .i2_fair_vocabularies, .met = true, .note = "It uses SKOS, Dublin Core terms and PROV-O." }, - .{ .principle = .i3_qualified_references, .met = true, .note = "Relations are typed: broader, narrower, related, and part-of for partitive relations." }, - .{ .principle = .r1_rich_attributes, .met = true, .note = "Concepts carry deprecated terms, examples, notes and sources." }, - .{ .principle = .r1_1_licence, .met = true, .note = "Published under the repository licence, stated in the scheme metadata." }, - .{ .principle = .r1_2_provenance, .met = true, .note = "Generated from source in the repository, with the generator recorded." }, - .{ .principle = .r1_3_domain_standards, .met = true, .note = "Definitions follow ISO 704 and are checked by the terminology tests." }, - }, - }; -} - -const testing = std.testing; - -test "every principle asks a question" { - inline for (std.meta.fields(Principle)) |field| { - const principle: Principle = @enumFromInt(field.value); - const question = principle.question(); - try testing.expectEqual(@as(u8, '?'), question[question.len - 1]); - } -} - -test "the vocabulary assessment names its one gap" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const assessment = vocabularyAssessment(); - const gaps = try assessment.unmet(arena); - try testing.expectEqual(@as(usize, 1), gaps.items.len); - try testing.expectEqual(Principle.f4_indexed, gaps.items[0].principle); - - try testing.expect(!assessment.letterMet('F')); - try testing.expect(assessment.letterMet('A')); - try testing.expect(assessment.letterMet('I')); - try testing.expect(assessment.letterMet('R')); -} - -test "the report refuses to give a score" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - var aw: std.Io.Writer.Allocating = .init(arena); - try vocabularyAssessment().writeReport(&aw.writer); - const text = aw.written(); - try testing.expect(std.mem.indexOf(u8, text, "not a certification") != null); - try testing.expect(std.mem.indexOf(u8, text, "12 of 13 principles met.") != null); -} diff --git a/src/interop/currency.zig b/src/interop/currency.zig deleted file mode 100644 index 4c255b8..0000000 --- a/src/interop/currency.zig +++ /dev/null @@ -1,174 +0,0 @@ -//! ISO 4217 currency identifiers and exact money values. -//! -//! Money is stored as an integer count of minor units plus a currency code. -//! Storing money as a float, or as the string "$", is the defect this module -//! exists to prevent. The `$`-style symbol belongs to presentation. - -const std = @import("std"); -const data = @import("iso4217_data.zig"); - -pub const Currency = data.Currency; -pub const table = data.table; - -pub const Error = error{ UnknownCurrency, CurrencyMismatch, AmountOverflow }; - -pub fn byCode(code: []const u8) ?Currency { - if (code.len != 3) return null; - var key: [3]u8 = undefined; - for (code, 0..) |c, i| key[i] = std.ascii.toUpper(c); - var low: usize = 0; - var high: usize = table.len; - while (low < high) { - const mid = low + (high - low) / 2; - switch (std.mem.order(u8, &table[mid].code, &key)) { - .lt => low = mid + 1, - .gt => high = mid, - .eq => return table[mid], - } - } - return null; -} - -pub const CurrencyCode = struct { - value: [3]u8, - - pub fn parse(code: []const u8) Error!CurrencyCode { - const entry = byCode(code) orelse return error.UnknownCurrency; - return .{ .value = entry.code }; - } - - pub fn minorUnits(self: CurrencyCode) u8 { - return (byCode(&self.value) orelse unreachable).minor_units; - } - - pub fn name(self: CurrencyCode) []const u8 { - return (byCode(&self.value) orelse unreachable).name; - } - - pub fn format(self: CurrencyCode, w: *std.Io.Writer) std.Io.Writer.Error!void { - try w.writeAll(&self.value); - } -}; - -/// An exact amount of money. `minor` counts the smallest unit the currency is -/// written in (cents for USD, whole yen for JPY, thousandths for KWD). -pub const Money = struct { - minor: i64, - currency: CurrencyCode, - - pub fn init(minor: i64, code: []const u8) Error!Money { - return .{ .minor = minor, .currency = try CurrencyCode.parse(code) }; - } - - pub fn add(a: Money, b: Money) Error!Money { - if (!std.mem.eql(u8, &a.currency.value, &b.currency.value)) return error.CurrencyMismatch; - const sum = std.math.add(i64, a.minor, b.minor) catch return error.AmountOverflow; - return .{ .minor = sum, .currency = a.currency }; - } - - pub fn negate(self: Money) Money { - return .{ .minor = -self.minor, .currency = self.currency }; - } - - /// Machine form: `"1234.56 USD"` with exactly the currency's decimal - /// digits, no grouping separators and no symbol. - pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void { - const digits = self.currency.minorUnits(); - const negative = self.minor < 0; - const magnitude: u64 = @intCast(if (negative) -self.minor else self.minor); - if (negative) try w.writeAll("-"); - if (digits == 0) { - try w.print("{d}", .{magnitude}); - } else { - var scale: u64 = 1; - for (0..digits) |_| scale *= 10; - try w.print("{d}.", .{magnitude / scale}); - // The fractional part is padded by hand so that 1.05 never prints - // as 1.5. - var remainder = magnitude % scale; - var divisor = scale / 10; - while (divisor > 0) : (divisor /= 10) { - try w.writeByte(@intCast('0' + (remainder / divisor))); - remainder %= divisor; - } - } - try w.print(" {s}", .{&self.currency.value}); - } - - /// Parse the machine form produced by `format`. - pub fn parse(text: []const u8) (Error || error{InvalidFormat})!Money { - const space = std.mem.lastIndexOfScalar(u8, text, ' ') orelse return error.InvalidFormat; - const code = try CurrencyCode.parse(text[space + 1 ..]); - const amount = std.mem.trim(u8, text[0..space], " "); - const digits = code.minorUnits(); - const negative = amount.len > 0 and amount[0] == '-'; - const body = if (negative) amount[1..] else amount; - const dot = std.mem.indexOfScalar(u8, body, '.'); - var minor: i64 = 0; - if (dot) |d| { - if (body.len - d - 1 != digits) return error.InvalidFormat; - const whole = std.fmt.parseInt(i64, body[0..d], 10) catch return error.InvalidFormat; - const frac = std.fmt.parseInt(i64, body[d + 1 ..], 10) catch return error.InvalidFormat; - var scale: i64 = 1; - for (0..digits) |_| scale *= 10; - minor = whole * scale + frac; - } else { - if (digits != 0) return error.InvalidFormat; - minor = std.fmt.parseInt(i64, body, 10) catch return error.InvalidFormat; - } - return .{ .minor = if (negative) -minor else minor, .currency = code }; - } -}; - -test "currency minor units follow iso 4217" { - try std.testing.expectEqual(@as(u8, 2), byCode("USD").?.minor_units); - try std.testing.expectEqual(@as(u8, 0), byCode("JPY").?.minor_units); - try std.testing.expectEqual(@as(u8, 3), byCode("KWD").?.minor_units); - try std.testing.expect(byCode("ZZZ") == null); -} - -test "money formats and parses exactly" { - const gpa = std.testing.allocator; - const usd = try Money.init(123456, "USD"); - const text = try std.fmt.allocPrint(gpa, "{f}", .{usd}); - defer gpa.free(text); - try std.testing.expectEqualStrings("1234.56 USD", text); - try std.testing.expectEqual(@as(i64, 123456), (try Money.parse(text)).minor); - - const jpy = try Money.init(1500, "JPY"); - const jtext = try std.fmt.allocPrint(gpa, "{f}", .{jpy}); - defer gpa.free(jtext); - try std.testing.expectEqualStrings("1500 JPY", jtext); - - const kwd = try Money.init(1234, "KWD"); - const ktext = try std.fmt.allocPrint(gpa, "{f}", .{kwd}); - defer gpa.free(ktext); - try std.testing.expectEqualStrings("1.234 KWD", ktext); - - const negative = try Money.init(-50, "USD"); - const ntext = try std.fmt.allocPrint(gpa, "{f}", .{negative}); - defer gpa.free(ntext); - try std.testing.expectEqualStrings("-0.50 USD", ntext); -} - -test "money pads minor units" { - const gpa = std.testing.allocator; - const five_cents = try Money.init(105, "USD"); - const text = try std.fmt.allocPrint(gpa, "{f}", .{five_cents}); - defer gpa.free(text); - try std.testing.expectEqualStrings("1.05 USD", text); - try std.testing.expectEqual(@as(i64, 105), (try Money.parse(text)).minor); - - const kwd = try Money.init(1004, "KWD"); - const ktext = try std.fmt.allocPrint(gpa, "{f}", .{kwd}); - defer gpa.free(ktext); - try std.testing.expectEqualStrings("1.004 KWD", ktext); -} - -test "money refuses to mix currencies" { - const a = try Money.init(100, "USD"); - const b = try Money.init(100, "EUR"); - try std.testing.expectError(error.CurrencyMismatch, a.add(b)); - const c = try a.add(try Money.init(23, "USD")); - try std.testing.expectEqual(@as(i64, 123), c.minor); -} diff --git a/src/interop/iso4217_data.zig b/src/interop/iso4217_data.zig deleted file mode 100644 index a806bce..0000000 --- a/src/interop/iso4217_data.zig +++ /dev/null @@ -1,198 +0,0 @@ -// GENERATED FILE - do not edit by hand. -// Regenerate with: python3 tools/gen_codes.py -// Source: ISO 4217 via pycountry; minor units via CLDR/babel -// Generated: 2026-09-04T21:18:03Z - -const std = @import("std"); - -/// One ISO 4217 currency entry. `minor_units` is the number of decimal digits -/// the currency is normally written with; amounts are stored as integers in -/// those minor units so that money never travels as a float. -pub const Currency = struct { - code: [3]u8, - numeric: u16, - minor_units: u8, - name: []const u8, -}; - -/// Sorted by alphabetic code so lookups can use binary search. -pub const table = [_]Currency{ - .{ .code = "AED".*, .numeric = 784, .minor_units = 2, .name = "UAE Dirham" }, - .{ .code = "AFN".*, .numeric = 971, .minor_units = 0, .name = "Afghani" }, - .{ .code = "ALL".*, .numeric = 8, .minor_units = 0, .name = "Lek" }, - .{ .code = "AMD".*, .numeric = 51, .minor_units = 2, .name = "Armenian Dram" }, - .{ .code = "AOA".*, .numeric = 973, .minor_units = 2, .name = "Kwanza" }, - .{ .code = "ARS".*, .numeric = 32, .minor_units = 2, .name = "Argentine Peso" }, - .{ .code = "AUD".*, .numeric = 36, .minor_units = 2, .name = "Australian Dollar" }, - .{ .code = "AWG".*, .numeric = 533, .minor_units = 2, .name = "Aruban Florin" }, - .{ .code = "AZN".*, .numeric = 944, .minor_units = 2, .name = "Azerbaijan Manat" }, - .{ .code = "BAM".*, .numeric = 977, .minor_units = 2, .name = "Convertible Mark" }, - .{ .code = "BBD".*, .numeric = 52, .minor_units = 2, .name = "Barbados Dollar" }, - .{ .code = "BDT".*, .numeric = 50, .minor_units = 2, .name = "Taka" }, - .{ .code = "BHD".*, .numeric = 48, .minor_units = 3, .name = "Bahraini Dinar" }, - .{ .code = "BIF".*, .numeric = 108, .minor_units = 0, .name = "Burundi Franc" }, - .{ .code = "BMD".*, .numeric = 60, .minor_units = 2, .name = "Bermudian Dollar" }, - .{ .code = "BND".*, .numeric = 96, .minor_units = 2, .name = "Brunei Dollar" }, - .{ .code = "BOB".*, .numeric = 68, .minor_units = 2, .name = "Boliviano" }, - .{ .code = "BOV".*, .numeric = 984, .minor_units = 2, .name = "Mvdol" }, - .{ .code = "BRL".*, .numeric = 986, .minor_units = 2, .name = "Brazilian Real" }, - .{ .code = "BSD".*, .numeric = 44, .minor_units = 2, .name = "Bahamian Dollar" }, - .{ .code = "BTN".*, .numeric = 64, .minor_units = 2, .name = "Ngultrum" }, - .{ .code = "BWP".*, .numeric = 72, .minor_units = 2, .name = "Pula" }, - .{ .code = "BYN".*, .numeric = 933, .minor_units = 2, .name = "Belarusian Ruble" }, - .{ .code = "BZD".*, .numeric = 84, .minor_units = 2, .name = "Belize Dollar" }, - .{ .code = "CAD".*, .numeric = 124, .minor_units = 2, .name = "Canadian Dollar" }, - .{ .code = "CDF".*, .numeric = 976, .minor_units = 2, .name = "Congolese Franc" }, - .{ .code = "CHE".*, .numeric = 947, .minor_units = 2, .name = "WIR Euro" }, - .{ .code = "CHF".*, .numeric = 756, .minor_units = 2, .name = "Swiss Franc" }, - .{ .code = "CHW".*, .numeric = 948, .minor_units = 2, .name = "WIR Franc" }, - .{ .code = "CLF".*, .numeric = 990, .minor_units = 4, .name = "Unidad de Fomento" }, - .{ .code = "CLP".*, .numeric = 152, .minor_units = 0, .name = "Chilean Peso" }, - .{ .code = "CNY".*, .numeric = 156, .minor_units = 2, .name = "Yuan Renminbi" }, - .{ .code = "COP".*, .numeric = 170, .minor_units = 2, .name = "Colombian Peso" }, - .{ .code = "COU".*, .numeric = 970, .minor_units = 2, .name = "Unidad de Valor Real" }, - .{ .code = "CRC".*, .numeric = 188, .minor_units = 2, .name = "Costa Rican Colon" }, - .{ .code = "CUP".*, .numeric = 192, .minor_units = 2, .name = "Cuban Peso" }, - .{ .code = "CVE".*, .numeric = 132, .minor_units = 2, .name = "Cabo Verde Escudo" }, - .{ .code = "CZK".*, .numeric = 203, .minor_units = 2, .name = "Czech Koruna" }, - .{ .code = "DJF".*, .numeric = 262, .minor_units = 0, .name = "Djibouti Franc" }, - .{ .code = "DKK".*, .numeric = 208, .minor_units = 2, .name = "Danish Krone" }, - .{ .code = "DOP".*, .numeric = 214, .minor_units = 2, .name = "Dominican Peso" }, - .{ .code = "DZD".*, .numeric = 12, .minor_units = 2, .name = "Algerian Dinar" }, - .{ .code = "EGP".*, .numeric = 818, .minor_units = 2, .name = "Egyptian Pound" }, - .{ .code = "ERN".*, .numeric = 232, .minor_units = 2, .name = "Nakfa" }, - .{ .code = "ETB".*, .numeric = 230, .minor_units = 2, .name = "Ethiopian Birr" }, - .{ .code = "EUR".*, .numeric = 978, .minor_units = 2, .name = "Euro" }, - .{ .code = "FJD".*, .numeric = 242, .minor_units = 2, .name = "Fiji Dollar" }, - .{ .code = "FKP".*, .numeric = 238, .minor_units = 2, .name = "Falkland Islands Pound" }, - .{ .code = "GBP".*, .numeric = 826, .minor_units = 2, .name = "Pound Sterling" }, - .{ .code = "GEL".*, .numeric = 981, .minor_units = 2, .name = "Lari" }, - .{ .code = "GHS".*, .numeric = 936, .minor_units = 2, .name = "Ghana Cedi" }, - .{ .code = "GIP".*, .numeric = 292, .minor_units = 2, .name = "Gibraltar Pound" }, - .{ .code = "GMD".*, .numeric = 270, .minor_units = 2, .name = "Dalasi" }, - .{ .code = "GNF".*, .numeric = 324, .minor_units = 0, .name = "Guinean Franc" }, - .{ .code = "GTQ".*, .numeric = 320, .minor_units = 2, .name = "Quetzal" }, - .{ .code = "GYD".*, .numeric = 328, .minor_units = 2, .name = "Guyana Dollar" }, - .{ .code = "HKD".*, .numeric = 344, .minor_units = 2, .name = "Hong Kong Dollar" }, - .{ .code = "HNL".*, .numeric = 340, .minor_units = 2, .name = "Lempira" }, - .{ .code = "HTG".*, .numeric = 332, .minor_units = 2, .name = "Gourde" }, - .{ .code = "HUF".*, .numeric = 348, .minor_units = 2, .name = "Forint" }, - .{ .code = "IDR".*, .numeric = 360, .minor_units = 2, .name = "Rupiah" }, - .{ .code = "ILS".*, .numeric = 376, .minor_units = 2, .name = "New Israeli Sheqel" }, - .{ .code = "INR".*, .numeric = 356, .minor_units = 2, .name = "Indian Rupee" }, - .{ .code = "IQD".*, .numeric = 368, .minor_units = 0, .name = "Iraqi Dinar" }, - .{ .code = "IRR".*, .numeric = 364, .minor_units = 0, .name = "Iranian Rial" }, - .{ .code = "ISK".*, .numeric = 352, .minor_units = 0, .name = "Iceland Krona" }, - .{ .code = "JMD".*, .numeric = 388, .minor_units = 2, .name = "Jamaican Dollar" }, - .{ .code = "JOD".*, .numeric = 400, .minor_units = 3, .name = "Jordanian Dinar" }, - .{ .code = "JPY".*, .numeric = 392, .minor_units = 0, .name = "Yen" }, - .{ .code = "KES".*, .numeric = 404, .minor_units = 2, .name = "Kenyan Shilling" }, - .{ .code = "KGS".*, .numeric = 417, .minor_units = 2, .name = "Som" }, - .{ .code = "KHR".*, .numeric = 116, .minor_units = 2, .name = "Riel" }, - .{ .code = "KMF".*, .numeric = 174, .minor_units = 0, .name = "Comorian Franc" }, - .{ .code = "KPW".*, .numeric = 408, .minor_units = 0, .name = "North Korean Won" }, - .{ .code = "KRW".*, .numeric = 410, .minor_units = 0, .name = "Won" }, - .{ .code = "KWD".*, .numeric = 414, .minor_units = 3, .name = "Kuwaiti Dinar" }, - .{ .code = "KYD".*, .numeric = 136, .minor_units = 2, .name = "Cayman Islands Dollar" }, - .{ .code = "KZT".*, .numeric = 398, .minor_units = 2, .name = "Tenge" }, - .{ .code = "LAK".*, .numeric = 418, .minor_units = 0, .name = "Lao Kip" }, - .{ .code = "LBP".*, .numeric = 422, .minor_units = 0, .name = "Lebanese Pound" }, - .{ .code = "LKR".*, .numeric = 144, .minor_units = 2, .name = "Sri Lanka Rupee" }, - .{ .code = "LRD".*, .numeric = 430, .minor_units = 2, .name = "Liberian Dollar" }, - .{ .code = "LSL".*, .numeric = 426, .minor_units = 2, .name = "Loti" }, - .{ .code = "LYD".*, .numeric = 434, .minor_units = 3, .name = "Libyan Dinar" }, - .{ .code = "MAD".*, .numeric = 504, .minor_units = 2, .name = "Moroccan Dirham" }, - .{ .code = "MDL".*, .numeric = 498, .minor_units = 2, .name = "Moldovan Leu" }, - .{ .code = "MGA".*, .numeric = 969, .minor_units = 0, .name = "Malagasy Ariary" }, - .{ .code = "MKD".*, .numeric = 807, .minor_units = 2, .name = "Denar" }, - .{ .code = "MMK".*, .numeric = 104, .minor_units = 0, .name = "Kyat" }, - .{ .code = "MNT".*, .numeric = 496, .minor_units = 2, .name = "Tugrik" }, - .{ .code = "MOP".*, .numeric = 446, .minor_units = 2, .name = "Pataca" }, - .{ .code = "MRU".*, .numeric = 929, .minor_units = 2, .name = "Ouguiya" }, - .{ .code = "MUR".*, .numeric = 480, .minor_units = 2, .name = "Mauritius Rupee" }, - .{ .code = "MVR".*, .numeric = 462, .minor_units = 2, .name = "Rufiyaa" }, - .{ .code = "MWK".*, .numeric = 454, .minor_units = 2, .name = "Malawi Kwacha" }, - .{ .code = "MXN".*, .numeric = 484, .minor_units = 2, .name = "Mexican Peso" }, - .{ .code = "MXV".*, .numeric = 979, .minor_units = 2, .name = "Mexican Unidad de Inversion (UDI)" }, - .{ .code = "MYR".*, .numeric = 458, .minor_units = 2, .name = "Malaysian Ringgit" }, - .{ .code = "MZN".*, .numeric = 943, .minor_units = 2, .name = "Mozambique Metical" }, - .{ .code = "NAD".*, .numeric = 516, .minor_units = 2, .name = "Namibia Dollar" }, - .{ .code = "NGN".*, .numeric = 566, .minor_units = 2, .name = "Naira" }, - .{ .code = "NIO".*, .numeric = 558, .minor_units = 2, .name = "Cordoba Oro" }, - .{ .code = "NOK".*, .numeric = 578, .minor_units = 2, .name = "Norwegian Krone" }, - .{ .code = "NPR".*, .numeric = 524, .minor_units = 2, .name = "Nepalese Rupee" }, - .{ .code = "NZD".*, .numeric = 554, .minor_units = 2, .name = "New Zealand Dollar" }, - .{ .code = "OMR".*, .numeric = 512, .minor_units = 3, .name = "Rial Omani" }, - .{ .code = "PAB".*, .numeric = 590, .minor_units = 2, .name = "Balboa" }, - .{ .code = "PEN".*, .numeric = 604, .minor_units = 2, .name = "Sol" }, - .{ .code = "PGK".*, .numeric = 598, .minor_units = 2, .name = "Kina" }, - .{ .code = "PHP".*, .numeric = 608, .minor_units = 2, .name = "Philippine Peso" }, - .{ .code = "PKR".*, .numeric = 586, .minor_units = 2, .name = "Pakistan Rupee" }, - .{ .code = "PLN".*, .numeric = 985, .minor_units = 2, .name = "Zloty" }, - .{ .code = "PYG".*, .numeric = 600, .minor_units = 0, .name = "Guarani" }, - .{ .code = "QAR".*, .numeric = 634, .minor_units = 2, .name = "Qatari Rial" }, - .{ .code = "RON".*, .numeric = 946, .minor_units = 2, .name = "Romanian Leu" }, - .{ .code = "RSD".*, .numeric = 941, .minor_units = 0, .name = "Serbian Dinar" }, - .{ .code = "RUB".*, .numeric = 643, .minor_units = 2, .name = "Russian Ruble" }, - .{ .code = "RWF".*, .numeric = 646, .minor_units = 0, .name = "Rwanda Franc" }, - .{ .code = "SAR".*, .numeric = 682, .minor_units = 2, .name = "Saudi Riyal" }, - .{ .code = "SBD".*, .numeric = 90, .minor_units = 2, .name = "Solomon Islands Dollar" }, - .{ .code = "SCR".*, .numeric = 690, .minor_units = 2, .name = "Seychelles Rupee" }, - .{ .code = "SDG".*, .numeric = 938, .minor_units = 2, .name = "Sudanese Pound" }, - .{ .code = "SEK".*, .numeric = 752, .minor_units = 2, .name = "Swedish Krona" }, - .{ .code = "SGD".*, .numeric = 702, .minor_units = 2, .name = "Singapore Dollar" }, - .{ .code = "SHP".*, .numeric = 654, .minor_units = 2, .name = "Saint Helena Pound" }, - .{ .code = "SLE".*, .numeric = 925, .minor_units = 2, .name = "Leone" }, - .{ .code = "SOS".*, .numeric = 706, .minor_units = 0, .name = "Somali Shilling" }, - .{ .code = "SRD".*, .numeric = 968, .minor_units = 2, .name = "Surinam Dollar" }, - .{ .code = "SSP".*, .numeric = 728, .minor_units = 2, .name = "South Sudanese Pound" }, - .{ .code = "STN".*, .numeric = 930, .minor_units = 2, .name = "Dobra" }, - .{ .code = "SVC".*, .numeric = 222, .minor_units = 2, .name = "El Salvador Colon" }, - .{ .code = "SYP".*, .numeric = 760, .minor_units = 0, .name = "Syrian Pound" }, - .{ .code = "SZL".*, .numeric = 748, .minor_units = 2, .name = "Lilangeni" }, - .{ .code = "THB".*, .numeric = 764, .minor_units = 2, .name = "Baht" }, - .{ .code = "TJS".*, .numeric = 972, .minor_units = 2, .name = "Somoni" }, - .{ .code = "TMT".*, .numeric = 934, .minor_units = 2, .name = "Turkmenistan New Manat" }, - .{ .code = "TND".*, .numeric = 788, .minor_units = 3, .name = "Tunisian Dinar" }, - .{ .code = "TOP".*, .numeric = 776, .minor_units = 2, .name = "Pa’anga" }, - .{ .code = "TRY".*, .numeric = 949, .minor_units = 2, .name = "Turkish Lira" }, - .{ .code = "TTD".*, .numeric = 780, .minor_units = 2, .name = "Trinidad and Tobago Dollar" }, - .{ .code = "TWD".*, .numeric = 901, .minor_units = 2, .name = "New Taiwan Dollar" }, - .{ .code = "TZS".*, .numeric = 834, .minor_units = 2, .name = "Tanzanian Shilling" }, - .{ .code = "UAH".*, .numeric = 980, .minor_units = 2, .name = "Hryvnia" }, - .{ .code = "UGX".*, .numeric = 800, .minor_units = 0, .name = "Uganda Shilling" }, - .{ .code = "USD".*, .numeric = 840, .minor_units = 2, .name = "US Dollar" }, - .{ .code = "USN".*, .numeric = 997, .minor_units = 2, .name = "US Dollar (Next day)" }, - .{ .code = "UYI".*, .numeric = 940, .minor_units = 0, .name = "Uruguay Peso en Unidades Indexadas (UI)" }, - .{ .code = "UYU".*, .numeric = 858, .minor_units = 2, .name = "Peso Uruguayo" }, - .{ .code = "UYW".*, .numeric = 927, .minor_units = 4, .name = "Unidad Previsional" }, - .{ .code = "UZS".*, .numeric = 860, .minor_units = 2, .name = "Uzbekistan Sum" }, - .{ .code = "VED".*, .numeric = 926, .minor_units = 2, .name = "Bolívar Soberano" }, - .{ .code = "VES".*, .numeric = 928, .minor_units = 2, .name = "Bolívar Soberano" }, - .{ .code = "VND".*, .numeric = 704, .minor_units = 0, .name = "Dong" }, - .{ .code = "VUV".*, .numeric = 548, .minor_units = 0, .name = "Vatu" }, - .{ .code = "WST".*, .numeric = 882, .minor_units = 2, .name = "Tala" }, - .{ .code = "XAD".*, .numeric = 396, .minor_units = 2, .name = "Arab Accounting Dinar" }, - .{ .code = "XAF".*, .numeric = 950, .minor_units = 0, .name = "CFA Franc BEAC" }, - .{ .code = "XAG".*, .numeric = 961, .minor_units = 2, .name = "Silver" }, - .{ .code = "XAU".*, .numeric = 959, .minor_units = 2, .name = "Gold" }, - .{ .code = "XBA".*, .numeric = 955, .minor_units = 2, .name = "Bond Markets Unit European Composite Unit (EURCO)" }, - .{ .code = "XBB".*, .numeric = 956, .minor_units = 2, .name = "Bond Markets Unit European Monetary Unit (E.M.U.-6)" }, - .{ .code = "XBC".*, .numeric = 957, .minor_units = 2, .name = "Bond Markets Unit European Unit of Account 9 (E.U.A.-9)" }, - .{ .code = "XBD".*, .numeric = 958, .minor_units = 2, .name = "Bond Markets Unit European Unit of Account 17 (E.U.A.-17)" }, - .{ .code = "XCD".*, .numeric = 951, .minor_units = 2, .name = "East Caribbean Dollar" }, - .{ .code = "XCG".*, .numeric = 532, .minor_units = 2, .name = "Caribbean Guilder" }, - .{ .code = "XDR".*, .numeric = 960, .minor_units = 2, .name = "SDR (Special Drawing Right)" }, - .{ .code = "XOF".*, .numeric = 952, .minor_units = 0, .name = "CFA Franc BCEAO" }, - .{ .code = "XPD".*, .numeric = 964, .minor_units = 2, .name = "Palladium" }, - .{ .code = "XPF".*, .numeric = 953, .minor_units = 0, .name = "CFP Franc" }, - .{ .code = "XPT".*, .numeric = 962, .minor_units = 2, .name = "Platinum" }, - .{ .code = "XSU".*, .numeric = 994, .minor_units = 2, .name = "Sucre" }, - .{ .code = "XTS".*, .numeric = 963, .minor_units = 2, .name = "Codes specifically reserved for testing purposes" }, - .{ .code = "XUA".*, .numeric = 965, .minor_units = 2, .name = "ADB Unit of Account" }, - .{ .code = "XXX".*, .numeric = 999, .minor_units = 2, .name = "The codes assigned for transactions where no currency is involved" }, - .{ .code = "YER".*, .numeric = 886, .minor_units = 0, .name = "Yemeni Rial" }, - .{ .code = "ZAR".*, .numeric = 710, .minor_units = 2, .name = "Rand" }, - .{ .code = "ZMW".*, .numeric = 967, .minor_units = 2, .name = "Zambian Kwacha" }, - .{ .code = "ZWG".*, .numeric = 924, .minor_units = 2, .name = "Zimbabwe Gold" }, -}; diff --git a/src/language/controlled.zig b/src/language/controlled.zig deleted file mode 100644 index b5d73f3..0000000 --- a/src/language/controlled.zig +++ /dev/null @@ -1,406 +0,0 @@ -//! Simplified Technical English profile (ASD-STE100). -//! -//! Controlled English is not "plainer" English; it is a *restricted* language. -//! One word, one meaning, one part of speech, and procedures written as short -//! imperative sentences. That restriction is what makes a procedure translate -//! and read the same way for every reader, so this profile is applied to -//! procedures, diagnostics and support material — never to conceptual prose, -//! where it would flatten the meaning. -//! -//! The dictionary shipped here is a small working subset: the approved words a -//! terminal workbench actually needs, plus the technical names and verbs the -//! specification lets a project add to its own dictionary. - -const std = @import("std"); -const text = @import("text.zig"); - -pub const Rule = enum { - sentence_too_long, - word_not_approved, - disallowed_verb_form, - paragraph_too_long, - more_than_one_instruction, - noun_cluster, - not_imperative, - contraction, - helping_verb_in_procedure, - - pub fn code(self: Rule) []const u8 { - return switch (self) { - .sentence_too_long => "STE101", - .word_not_approved => "STE102", - .disallowed_verb_form => "STE103", - .paragraph_too_long => "STE104", - .more_than_one_instruction => "STE105", - .noun_cluster => "STE106", - .not_imperative => "STE107", - .contraction => "STE108", - .helping_verb_in_procedure => "STE109", - }; - } -}; - -pub const Finding = struct { - rule: Rule, - line: usize, - column: usize, - excerpt: []const u8, - message: []const u8, - suggestion: ?[]const u8 = null, -}; - -pub const TextType = enum { - /// Steps a reader carries out. The strictest form. - procedural, - /// Explanation of how something works. - descriptive, - - pub fn sentenceLimit(self: TextType) usize { - return switch (self) { - .procedural => 20, - .descriptive => 25, - }; - } -}; - -pub const Options = struct { - text_type: TextType = .procedural, - /// Words this project has added to its technical-name dictionary. - project_dictionary: []const []const u8 = &.{}, - max_paragraph_sentences: usize = 6, -}; - -/// Approved words with the meaning they carry in Simplified Technical English. -/// The list is short on purpose: a controlled dictionary is a maintained asset, -/// not an attempt to cover all of English. -pub const approved_words = [_][]const u8{ - // Approved verbs - "add", "adjust", "allow", "apply", "attach", "build", "cancel", "change", - "check", "close", "connect", "continue", "copy", "create", "cut", "delete", - "disable", "do", "enable", "end", "enter", "examine", "find", "get", - "give", "hold", "install", "keep", "let", "look", "make", "measure", - "monitor", "move", "open", "operate", "prevent", "print", "push", "put", - "read", "release", "remove", "repair", "replace", "reset", "run", "save", - "select", "send", "set", "show", "start", "stop", "test", "try", - "turn", "use", "wait", "write", - // Approved function words - "a", "an", "the", "and", - "or", "but", "if", "then", "than", "that", "this", "these", - "those", "with", "without", "from", "to", "into", "on", "off", - "in", "out", "at", "by", "for", "of", "not", "no", - "all", "each", "every", "more", "less", "first", "last", "next", - "again", "also", "only", "before", "after", "when", "while", "you", - "your", "it", "its", "is", "are", "be", "can", "will", - "must", "do", "does", "did", - // Approved nouns used across the product - "agent", "block", "button", "command", - "computer", "data", "directory", "error", "file", "folder", "key", "line", - "list", "log", "message", "name", "network", "number", "option", "output", - "page", "path", "process", "program", "prompt", "record", "result", "screen", - "server", "session", "shell", "system", "task", "terminal", "test", "text", - "time", "tool", "user", "value", "window", "workspace", "step", "menu", - "panel", "report", "rule", "policy", "second", "minute", "hour", "day", -}; - -/// Words the specification replaces, with the approved word to use. -pub const replacements = [_]text.Substitution{ - .{ .from = "abort", .to = "stop" }, - .{ .from = "kill", .to = "stop" }, - .{ .from = "utilize", .to = "use" }, - .{ .from = "terminate", .to = "end" }, - .{ .from = "commence", .to = "start" }, - .{ .from = "initiate", .to = "start" }, - .{ .from = "obtain", .to = "get" }, - .{ .from = "perform", .to = "do" }, - .{ .from = "ensure", .to = "make sure" }, - .{ .from = "attempt", .to = "try" }, - .{ .from = "modify", .to = "change" }, - .{ .from = "purge", .to = "remove" }, - .{ .from = "spawn", .to = "start" }, - .{ .from = "invoke", .to = "run" }, - .{ .from = "leverage", .to = "use" }, - .{ .from = "prior", .to = "before" }, -}; - -pub fn isApproved(word: []const u8, options: Options) bool { - for (approved_words) |a| { - if (std.ascii.eqlIgnoreCase(a, word)) return true; - } - for (options.project_dictionary) |a| { - if (std.ascii.eqlIgnoreCase(a, word)) return true; - } - return false; -} - -fn replacementFor(word: []const u8) ?[]const u8 { - for (replacements) |r| { - if (std.ascii.eqlIgnoreCase(r.from, word)) return r.to; - } - return null; -} - -fn isConditionOpener(word: []const u8) bool { - const openers = [_][]const u8{ "if", "when", "before", "after", "while", "note", "warning", "caution", "do" }; - for (openers) |o| { - if (std.ascii.eqlIgnoreCase(o, word)) return true; - } - return false; -} - -/// A technical name — a path, an identifier, a number, a flag — is allowed in -/// any position and is not checked against the dictionary. -fn isTechnicalName(word: []const u8) bool { - if (word.len == 0) return false; - if (std.ascii.isDigit(word[0])) return true; - if (std.ascii.isUpper(word[0])) return true; - for (word) |c| { - if (c == '/' or c == '.' or c == '_' or c == '-' or c == '{' or c == '}') return true; - } - return false; -} - -pub const Report = struct { - findings: []Finding, - - pub fn has(self: Report, rule: Rule) bool { - for (self.findings) |f| { - if (f.rule == rule) return true; - } - return false; - } - - pub fn count(self: Report) usize { - return self.findings.len; - } -}; - -pub fn check(arena: std.mem.Allocator, source: []const u8, options: Options) !Report { - var findings: std.ArrayList(Finding) = .empty; - const analysis = try text.analyse(arena, source); - const limit = options.text_type.sentenceLimit(); - - for (analysis.sentences, 0..) |sentence, index| { - const position = analysis.positionOf(sentence.span.start); - const words = analysis.wordsOf(index); - - if (sentence.words > limit) { - try findings.append(arena, .{ - .rule = .sentence_too_long, - .line = position.line, - .column = position.column, - .excerpt = sentence.text, - .message = try std.fmt.allocPrint(arena, "This sentence has {d} words. In this profile, {s} sentences hold {d} words or fewer.", .{ sentence.words, @tagName(options.text_type), limit }), - }); - } - - // A procedural sentence starts with the verb. - if (options.text_type == .procedural and words.len > 0) { - const first = text.trimWord(words[0].text); - // A step opens with the verb, or with the condition that decides - // whether the reader carries it out. - if (!text.isImperativeVerb(first) and !isConditionOpener(first)) { - try findings.append(arena, .{ - .rule = .not_imperative, - .line = position.line, - .column = position.column, - .excerpt = sentence.text, - .message = "A step starts with the verb for the action.", - .suggestion = "Open the file. Run the test.", - }); - } - } - - // One instruction per sentence. - if (options.text_type == .procedural) { - var verbs: usize = 0; - for (words, 0..) |word, i| { - const t = text.trimWord(word.text); - if (!text.isImperativeVerb(t)) continue; - if (i == 0) { - verbs += 1; - continue; - } - const previous = text.trimWord(words[i - 1].text); - if (std.ascii.eqlIgnoreCase(previous, "and") or std.ascii.eqlIgnoreCase(previous, "then") or std.ascii.eqlIgnoreCase(previous, "or")) verbs += 1; - } - if (verbs > 1) { - try findings.append(arena, .{ - .rule = .more_than_one_instruction, - .line = position.line, - .column = position.column, - .excerpt = sentence.text, - .message = "This sentence gives more than one instruction. Write one step for each action.", - }); - } - } - - var noun_run: usize = 0; - for (words) |word| { - const raw = text.trimWord(word.text); - if (raw.len == 0) continue; - const at = analysis.positionOf(word.span.start); - - if (std.mem.indexOfScalar(u8, raw, '\'') != null and raw.len > 2) { - try findings.append(arena, .{ - .rule = .contraction, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is a contraction. Write both words.", .{raw}), - }); - } - - if (replacementFor(raw)) |better| { - try findings.append(arena, .{ - .rule = .word_not_approved, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is not in the approved dictionary.", .{raw}), - .suggestion = better, - }); - } else if (!isApproved(raw, options) and !isTechnicalName(raw) and raw.len > 2) { - try findings.append(arena, .{ - .rule = .word_not_approved, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is not in the approved dictionary or the project dictionary. Add it to the project dictionary, or use an approved word.", .{raw}), - }); - } - - // Verb forms: no gerunds as verbs, no past tense in procedures. - if (options.text_type == .procedural) { - if (raw.len > 5 and std.mem.endsWith(u8, raw, "ing") and !isTechnicalName(raw)) { - try findings.append(arena, .{ - .rule = .disallowed_verb_form, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is an -ing form. Use the plain verb in a step.", .{raw}), - }); - } else if (text.looksLikePastParticiple(raw) and raw.len > 4) { - try findings.append(arena, .{ - .rule = .disallowed_verb_form, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is a past form. A step is written in the present, as an instruction.", .{raw}), - }); - } - if (std.ascii.eqlIgnoreCase(raw, "should") or std.ascii.eqlIgnoreCase(raw, "shall") or std.ascii.eqlIgnoreCase(raw, "would")) { - try findings.append(arena, .{ - .rule = .helping_verb_in_procedure, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" leaves the reader unsure whether to act. Write the step as an instruction.", .{raw}), - }); - } - } - - if (raw.len > 3 and std.ascii.isLower(raw[0]) and !text.isBeVerb(raw) and !text.isModal(raw)) { - noun_run += 1; - if (noun_run == 4) { - try findings.append(arena, .{ - .rule = .noun_cluster, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = "Four or more words in a row without a linking word make a noun cluster. Break it up.", - }); - } - } else { - noun_run = 0; - } - } - } - - for (analysis.paragraphs) |paragraph| { - var count: usize = 0; - for (analysis.sentences) |s| { - if (s.span.start >= paragraph.start and s.span.start < paragraph.end) count += 1; - } - if (count > options.max_paragraph_sentences) { - const position = analysis.positionOf(paragraph.start); - try findings.append(arena, .{ - .rule = .paragraph_too_long, - .line = position.line, - .column = position.column, - .excerpt = "", - .message = try std.fmt.allocPrint(arena, "This paragraph has {d} sentences. Keep paragraphs to {d} or fewer.", .{ count, options.max_paragraph_sentences }), - }); - } - } - - return .{ .findings = try findings.toOwnedSlice(arena) }; -} - -/// What this profile cannot decide, stated wherever it is reported. -pub const limits_statement = - "This profile checks the rules a program can check: sentence length, word list, verb form and " ++ - "sentence structure. It does not decide whether a word carries the right technical meaning. " ++ - "A controlled-language dictionary is maintained by people."; - -const workbench_dictionary = [_][]const u8{ "workspace", "agent", "worktree", "repository", "branch", "commit", "again", "permission", "capability" }; - -test "procedures must be short imperative sentences" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const good = try check(arena, "Open the file. Run the test.", .{ .project_dictionary = &workbench_dictionary }); - try std.testing.expect(!good.has(.not_imperative)); - try std.testing.expect(!good.has(.sentence_too_long)); - - const bad = try check(arena, "The session should be terminated by the operator when the process has finished running its tests.", .{ .project_dictionary = &workbench_dictionary }); - try std.testing.expect(bad.has(.not_imperative)); - try std.testing.expect(bad.has(.helping_verb_in_procedure)); - try std.testing.expect(bad.has(.disallowed_verb_form)); -} - -test "unapproved words are replaced with approved ones" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const report = try check(arena, "Terminate the process.", .{ .project_dictionary = &workbench_dictionary }); - try std.testing.expect(report.has(.word_not_approved)); - var suggested = false; - for (report.findings) |f| { - if (f.rule == .word_not_approved and f.suggestion != null and std.mem.eql(u8, f.suggestion.?, "end")) suggested = true; - } - try std.testing.expect(suggested); -} - -test "one instruction per sentence" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const two = try check(arena, "Open the file and run the test.", .{ .project_dictionary = &workbench_dictionary }); - try std.testing.expect(two.has(.more_than_one_instruction)); - - const one = try check(arena, "Open the file.", .{ .project_dictionary = &workbench_dictionary }); - try std.testing.expect(!one.has(.more_than_one_instruction)); -} - -test "contractions are not allowed" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - const report = try check(arena, "Do not use it if it doesn't start.", .{ .project_dictionary = &workbench_dictionary }); - try std.testing.expect(report.has(.contraction)); -} - -test "descriptive text gets the longer sentence limit" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const sentence = "The workspace keeps a record of each command and each result so that you can find the cause of a failure later on."; - const as_procedure = try check(arena, sentence, .{ .text_type = .procedural, .project_dictionary = &workbench_dictionary }); - const as_description = try check(arena, sentence, .{ .text_type = .descriptive, .project_dictionary = &workbench_dictionary }); - try std.testing.expect(as_procedure.has(.sentence_too_long)); - try std.testing.expect(!as_description.has(.sentence_too_long)); -} diff --git a/src/language/easy_read.zig b/src/language/easy_read.zig deleted file mode 100644 index 9dcf0d9..0000000 --- a/src/language/easy_read.zig +++ /dev/null @@ -1,285 +0,0 @@ -//! Easy-to-Read profile (Inclusion Europe guidance). -//! -//! This is a separate audience profile, not a "simpler" setting for everything. -//! Easy-to-Read material is written for people who need short sentences, common -//! words, one idea at a time and supporting pictures. Applying it to reference -//! documentation for expert readers would remove precision those readers need; -//! withholding it from the material that needs it excludes people. So the -//! profile is selected by audience, and the checker says plainly which of its -//! rules a program cannot decide. - -const std = @import("std"); -const text = @import("text.zig"); - -pub const Rule = enum { - sentence_too_long, - more_than_one_idea, - difficult_word, - figure_of_speech, - passive_voice, - abbreviation, - negative_construction, - large_number, - percentage, - special_character, - - pub fn code(self: Rule) []const u8 { - return switch (self) { - .sentence_too_long => "ER01", - .more_than_one_idea => "ER02", - .difficult_word => "ER03", - .figure_of_speech => "ER04", - .passive_voice => "ER05", - .abbreviation => "ER06", - .negative_construction => "ER07", - .large_number => "ER08", - .percentage => "ER09", - .special_character => "ER10", - }; - } -}; - -pub const Finding = struct { - rule: Rule, - line: usize, - column: usize, - excerpt: []const u8, - message: []const u8, - suggestion: ?[]const u8 = null, -}; - -pub const Options = struct { - max_sentence_words: usize = 15, - /// Words the document explains where it first uses them. - explained_words: []const []const u8 = &.{}, -}; - -pub const Report = struct { - findings: []Finding, - /// Checks that a person must carry out, listed with every report. - pub const manual_checks = [_][]const u8{ - "A person with a learning disability read this text and said it was clear.", - "Each idea has a picture, a symbol or an example that supports it.", - "The text is set in a large, plain typeface with one sentence on each line.", - "Important words are used the same way every time they appear.", - }; - - pub fn has(self: Report, rule: Rule) bool { - for (self.findings) |f| { - if (f.rule == rule) return true; - } - return false; - } - - pub fn writeText(self: Report, w: *std.Io.Writer) std.Io.Writer.Error!void { - for (self.findings) |f| { - try w.print("{s} {d}:{d} {s}\n", .{ f.rule.code(), f.line, f.column, f.message }); - if (f.suggestion) |s| try w.print(" try: {s}\n", .{s}); - } - try w.writeAll("\nA program cannot decide these. A person must check them:\n"); - for (manual_checks) |c| try w.print(" - {s}\n", .{c}); - } -}; - -pub fn check(arena: std.mem.Allocator, source: []const u8, options: Options) !Report { - var findings: std.ArrayList(Finding) = .empty; - const analysis = try text.analyse(arena, source); - - for (analysis.sentences, 0..) |sentence, index| { - const position = analysis.positionOf(sentence.span.start); - const words = analysis.wordsOf(index); - - if (sentence.words > options.max_sentence_words) { - try findings.append(arena, .{ - .rule = .sentence_too_long, - .line = position.line, - .column = position.column, - .excerpt = sentence.text, - .message = try std.fmt.allocPrint(arena, "This sentence has {d} words. Write sentences of {d} words or fewer.", .{ sentence.words, options.max_sentence_words }), - }); - } - - // One idea per sentence: a comma or a joining word usually adds one. - var joiners: usize = 0; - for (sentence.text) |c| { - if (c == ',' or c == ';') joiners += 1; - } - for (words) |word| { - const t = text.trimWord(word.text); - if (std.ascii.eqlIgnoreCase(t, "and") or std.ascii.eqlIgnoreCase(t, "but") or std.ascii.eqlIgnoreCase(t, "because") or std.ascii.eqlIgnoreCase(t, "which")) joiners += 1; - } - if (joiners >= 2) { - try findings.append(arena, .{ - .rule = .more_than_one_idea, - .line = position.line, - .column = position.column, - .excerpt = sentence.text, - .message = "This sentence holds more than one idea. Write one idea in each sentence.", - }); - } - - var w: usize = 0; - while (w + 1 < words.len) : (w += 1) { - if (text.isBeVerb(text.trimWord(words[w].text)) and text.looksLikePastParticiple(text.trimWord(words[w + 1].text))) { - const at = analysis.positionOf(words[w].span.start); - try findings.append(arena, .{ - .rule = .passive_voice, - .line = at.line, - .column = at.column, - .excerpt = try std.fmt.allocPrint(arena, "{s} {s}", .{ words[w].text, words[w + 1].text }), - .message = "Say who does the action. Write \"the agent opens the file\", not \"the file is opened\".", - }); - } - } - - for (words) |word| { - const raw = text.trimWord(word.text); - if (raw.len == 0) continue; - const at = analysis.positionOf(word.span.start); - - if (text.looksLikeAbbreviation(raw) and !isExplained(raw, options)) { - try findings.append(arena, .{ - .rule = .abbreviation, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is a short form. Write the words in full, and explain them.", .{raw}), - }); - continue; - } - - if (text.syllables(raw) >= 4 and raw.len > 8 and !isExplained(raw, options)) { - try findings.append(arena, .{ - .rule = .difficult_word, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is a long word. Use a common word, or explain this one where you first use it.", .{raw}), - }); - } - - if (std.ascii.eqlIgnoreCase(raw, "not") or std.ascii.eqlIgnoreCase(raw, "cannot") or std.ascii.eqlIgnoreCase(raw, "never")) { - try findings.append(arena, .{ - .rule = .negative_construction, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = "Say what the reader can do, rather than what they cannot do.", - }); - } - - if (std.mem.endsWith(u8, raw, "%")) { - try findings.append(arena, .{ - .rule = .percentage, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = "Percentages are hard to picture. Write \"1 in 4\" or \"most of them\".", - }); - } - - if (isLargeNumber(raw)) { - try findings.append(arena, .{ - .rule = .large_number, - .line = at.line, - .column = at.column, - .excerpt = raw, - .message = "Large numbers are hard to picture. Write \"many\" or compare it with something the reader knows.", - }); - } - } - } - - for (text.idioms) |idiom| { - if (text.findPhrase(source, idiom)) |found| { - const at = analysis.positionOf(found); - try findings.append(arena, .{ - .rule = .figure_of_speech, - .line = at.line, - .column = at.column, - .excerpt = idiom, - .message = try std.fmt.allocPrint(arena, "\"{s}\" is a figure of speech. Say what happens in plain words.", .{idiom}), - }); - } - } - - for (source, 0..) |c, i| { - if (c == '&' or c == '/' or c == '~' or c == '|' or c == '*' or c == '<' or c == '>') { - const at = analysis.positionOf(i); - try findings.append(arena, .{ - .rule = .special_character, - .line = at.line, - .column = at.column, - .excerpt = source[i .. i + 1], - .message = try std.fmt.allocPrint(arena, "The character \"{c}\" can be hard to read. Write the word instead.", .{c}), - }); - break; - } - } - - return .{ .findings = try findings.toOwnedSlice(arena) }; -} - -fn isExplained(word: []const u8, options: Options) bool { - for (options.explained_words) |e| { - if (std.ascii.eqlIgnoreCase(e, word)) return true; - } - return false; -} - -fn isLargeNumber(word: []const u8) bool { - var digits: usize = 0; - for (word) |c| { - if (std.ascii.isDigit(c)) digits += 1 else if (c != ',' and c != '.') return false; - } - return digits >= 4; -} - -test "long sentences and multiple ideas are reported" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const report = try check(arena, "The workspace keeps your work, and it saves each command, because you may want to read it later.", .{}); - try std.testing.expect(report.has(.sentence_too_long)); - try std.testing.expect(report.has(.more_than_one_idea)); - - const good = try check(arena, "The workspace saves your work. You can read it later.", .{}); - try std.testing.expect(!good.has(.sentence_too_long)); - try std.testing.expect(!good.has(.more_than_one_idea)); -} - -test "hard words, short forms and figures of speech are reported" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const report = try check(arena, "The PTY configuration works out of the box.", .{}); - try std.testing.expect(report.has(.abbreviation)); - try std.testing.expect(report.has(.difficult_word)); - try std.testing.expect(report.has(.figure_of_speech)); - - const explained = try check(arena, "The PTY is ready.", .{ .explained_words = &.{"PTY"} }); - try std.testing.expect(!explained.has(.abbreviation)); -} - -test "numbers and percentages are reported" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - const report = try check(arena, "We saved 12000 files. That is 30% of them.", .{}); - try std.testing.expect(report.has(.large_number)); - try std.testing.expect(report.has(.percentage)); -} - -test "the report lists what a program cannot decide" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const report = try check(arena, "Open the file.", .{}); - var aw: std.Io.Writer.Allocating = .init(arena); - try report.writeText(&aw.writer); - try std.testing.expect(std.mem.indexOf(u8, aw.written(), "A person must check them") != null); - try std.testing.expect(std.mem.indexOf(u8, aw.written(), "learning disability") != null); -} diff --git a/src/metadata/value_domain.zig b/src/metadata/value_domain.zig index 42ded0f..c6b084d 100644 --- a/src/metadata/value_domain.zig +++ b/src/metadata/value_domain.zig @@ -8,8 +8,6 @@ const std = @import("std"); const timeutil = @import("../core/time.zig"); const units = @import("../interop/units.zig"); -const countries = @import("../interop/countries.zig"); -const currency = @import("../interop/currency.zig"); const language = @import("../interop/language.zig"); pub const Datatype = enum { @@ -30,12 +28,6 @@ pub const Datatype = enum { code, /// Number with a unit. quantity, - /// Amount of money with a currency. - money, - /// ISO 3166-1 alpha-2. - country_code, - /// ISO 4217 alphabetic code. - currency_code, /// Language tag validated against ISO 639-1, ISO 15924 and ISO 3166-1. language_tag, /// Content hash in the workbench's canonical form. @@ -131,15 +123,6 @@ pub const ValueDomain = struct { if (!written.commensurableWith(expected)) return error.UnitMismatch; } }, - .money => { - _ = currency.Money.parse(value) catch return error.WrongDatatype; - }, - .country_code => { - _ = countries.CountryCode.parse(value) catch return error.UnknownCode; - }, - .currency_code => { - _ = currency.CurrencyCode.parse(value) catch return error.UnknownCode; - }, .language_tag => { _ = language.Tag.parse(value) catch return error.UnknownCode; }, @@ -186,20 +169,6 @@ test "quantities must use a commensurable unit" { try std.testing.expectError(error.WrongDatatype, domain.validate("8.5")); } -test "coded value domains reuse the iso registries" { - const country: ValueDomain = .{ .id = "country", .definition = "country of the operator", .datatype = .country_code }; - try country.validate("DE"); - try std.testing.expectError(error.UnknownCode, country.validate("XX")); - - const money: ValueDomain = .{ .id = "cost", .definition = "cost of one agent run", .datatype = .money }; - try money.validate("12.34 USD"); - try std.testing.expectError(error.WrongDatatype, money.validate("$12.34")); - - const tag: ValueDomain = .{ .id = "content-language", .definition = "language of the content", .datatype = .language_tag }; - try tag.validate("en-GB"); - try std.testing.expectError(error.UnknownCode, tag.validate("en-ZZ")); -} - test "ranges and lengths are enforced" { const domain: ValueDomain = .{ .id = "exit-status", diff --git a/src/reports/metrics.zig b/src/reports/metrics.zig index 309cfba..b4d1d4d 100644 --- a/src/reports/metrics.zig +++ b/src/reports/metrics.zig @@ -8,7 +8,6 @@ const std = @import("std"); const units = @import("../interop/units.zig"); const timeutil = @import("../core/time.zig"); -const currency = @import("../interop/currency.zig"); pub const Timestamp = timeutil.Timestamp; @@ -69,14 +68,8 @@ pub const Metric = struct { /// The number of decimal places to show. More precision than the /// measurement supports is a lie told with a decimal point. decimals: u8 = 0, - /// A currency code when the metric is money. - currency_code: ?[]const u8 = null, pub fn validate(self: Metric) !void { - if (self.currency_code) |code| { - _ = currency.CurrencyCode.parse(code) catch return error.UnknownCurrency; - return; - } _ = units.Unit.parse(self.unit) catch return error.UnknownUnit; } }; @@ -185,7 +178,7 @@ pub const Registry = struct { const testing = std.testing; -test "a metric must have a real unit or a real currency" { +test "a metric must have a real unit" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); var registry = Registry.init(arena_state.allocator()); @@ -208,16 +201,6 @@ test "a metric must have a real unit or a real currency" { .direction = .neutral, .source = "nowhere", })); - try testing.expectError(error.UnknownCurrency, registry.define(.{ - .id = "cost", - .name = "Cost", - .definition = "What the runs cost.", - .kind = .flow, - .unit = "1", - .direction = .lower_is_better, - .source = "provider invoices", - .currency_code = "XYZ", - })); } test "a value cannot be published for an undefined metric" { diff --git a/src/reports/notation.zig b/src/reports/notation.zig index d606975..4d01bb5 100644 --- a/src/reports/notation.zig +++ b/src/reports/notation.zig @@ -11,7 +11,6 @@ const std = @import("std"); const metrics = @import("metrics.zig"); -const currency_mod = @import("../interop/currency.zig"); const timeutil = @import("../core/time.zig"); pub const Metric = metrics.Metric; @@ -53,17 +52,8 @@ pub fn varianceOf(value: Value, metric: Metric) Variance { return if (improvement) .better else .worse; } -/// Write a number with the metric's precision. Money is written through the -/// currency rules; everything else takes the metric's decimal places. +/// Write a number with the metric's precision. pub fn writeNumber(w: *std.Io.Writer, metric: Metric, value: f64) !void { - if (metric.currency_code) |code| { - const currency_code = try currency_mod.CurrencyCode.parse(code); - var scale: f64 = 1; - for (0..currency_code.minorUnits()) |_| scale *= 10; - const money: currency_mod.Money = .{ .minor = @intFromFloat(@round(value * scale)), .currency = currency_code }; - try w.print("{f}", .{money}); - return; - } switch (metric.decimals) { 0 => try w.print("{d:.0}", .{value}), 1 => try w.print("{d:.1}", .{value}), @@ -75,7 +65,7 @@ pub fn writeNumber(w: *std.Io.Writer, metric: Metric, value: f64) !void { /// The full presentation of one value: number, unit, variance and confidence. pub fn writeValue(w: *std.Io.Writer, metric: Metric, value: Value) !void { try writeNumber(w, metric, value.value); - if (metric.currency_code == null and !std.mem.eql(u8, metric.unit, "1")) { + if (!std.mem.eql(u8, metric.unit, "1")) { try w.print(" {s}", .{metric.unit}); } @@ -210,7 +200,7 @@ pub const BarChart = struct { pub fn write(self: BarChart, w: *std.Io.Writer) !void { try w.print("{s}\n", .{self.title}); - try w.print("Measured in {s}. ", .{if (self.metric.currency_code) |c| c else self.metric.unit}); + try w.print("Measured in {s}. ", .{self.metric.unit}); try w.print("{s}\n\n", .{switch (self.metric.direction) { .higher_is_better => "Higher is better.", .lower_is_better => "Lower is better.", @@ -281,30 +271,6 @@ test "a falling number is better when lower is better" { try testing.expectEqualStrings("8.0 ms +20.0% against the period before", aw.written()); } -test "money is written through the currency rules" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const metric: Metric = .{ - .id = "cost", - .name = "Cost of agent runs", - .definition = "What the model provider charged for runs in this workspace.", - .kind = .flow, - .unit = "1", - .direction = .lower_is_better, - .source = "provider invoices", - .currency_code = "USD", - }; - var aw: std.Io.Writer.Allocating = .init(arena); - try writeValue(&aw.writer, metric, .{ - .metric_id = "cost", - .value = 12.5, - .period = .{ .start = timeutil.Timestamp.epoch, .end = timeutil.Timestamp.epoch }, - }); - try testing.expectEqualStrings("12.50 USD", aw.written()); -} - test "a sampled figure says so, with its sample size" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/root.zig b/src/root.zig index 4ad5eb7..38f6c3c 100644 --- a/src/root.zig +++ b/src/root.zig @@ -19,7 +19,6 @@ pub const core = struct { pub const interop = struct { pub const toml = @import("interop/toml.zig"); pub const countries = @import("interop/countries.zig"); - pub const currency = @import("interop/currency.zig"); pub const units = @import("interop/units.zig"); pub const jsonschema = @import("interop/jsonschema.zig"); pub const openapi = @import("interop/openapi.zig"); @@ -46,7 +45,6 @@ pub const data = struct { pub const provenance = @import("data/provenance.zig"); pub const lifecycle = @import("data/lifecycle.zig"); pub const quality = @import("data/quality.zig"); - pub const fair = @import("data/fair.zig"); }; pub const standards = struct { @@ -62,8 +60,6 @@ pub const language = struct { pub const audience = @import("language/audience.zig"); pub const readability = @import("language/readability.zig"); pub const plain = @import("language/plain.zig"); - pub const controlled = @import("language/controlled.zig"); - pub const easy_read = @import("language/easy_read.zig"); }; pub const events = struct { @@ -144,10 +140,6 @@ pub const records = struct { pub const content = struct { pub const ir = @import("content/ir.zig"); pub const markdown = @import("content/markdown.zig"); - pub const dita = @import("content/dita.zig"); - pub const docbook = @import("content/docbook.zig"); - pub const s1000d = @import("content/s1000d.zig"); - pub const iirds = @import("content/iirds.zig"); }; pub const accessibility = struct { From 58fd9110418546dcf16131ed1134ec0e8265e2a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 16:11:46 +0000 Subject: [PATCH 2/3] Split the repository audit out of the workbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zag` had thirty commands. Thirteen of them audited this repository's own documentation, standards registry and conformance evidence — work that has nothing to do with recording a shell session or asking a model a question, and that a person opening a terminal had to scroll past to find `zag term`. Those thirteen are now `zag-audit`: check, lint, terms, standards, emit, evidence, card, risk, impact, quality, accessibility, lifecycle, report. The split is by what a command looks at. `zag` looks at a workspace — its record, its policy, its shell, its history. `zag-audit` looks at a repository — its text, its standards, its generated files, its evidence. `src/cli/main.zig` goes from 3,333 lines to 1,897. The six standards registry TOML files are no longer compiled into the workbench binary; they ship in the audit tool, which is what reads them. Four options — --profile, --kind, --audience and --out — turned out to have no remaining user in `zag` at all and are gone from its help. Nothing was rewritten in the move, so a bug moved is still the same bug. Two things did have to change: The daemon's HTTP interface description lived in the command-line tool because `zag emit` reads it to generate the OpenAPI document. It describes the daemon, not either tool, and once the generator moved to a second binary it needed to be somewhere both could see. It is now `src/daemon/interface.zig` in the library, next to the daemon it describes. The conformance evidence recorded its generating tool as "zag check". It is generated by `zag-audit check` now, and the identity says so, because evidence that names a tool which no longer exists is evidence nobody can reproduce. `zig build check` and `zig build emit` are unchanged as commands and now run the audit binary. CI, the README and the architecture and decision documents are updated to the new spelling. 605 tests pass across four test binaries, the gate exits 0, and `zag help` now lists seventeen commands instead of thirty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- .github/workflows/ci.yml | 10 +- README.md | 26 +- build.zig | 37 +- docs/adr/0006-generated-not-maintained.md | 2 +- .../0008-knowledge-lives-in-the-workspace.md | 2 +- docs/architecture.md | 2 +- docs/glossary.md | 2 +- docs/risk-register.md | 2 +- docs/roadmap.md | 2 +- evidence/conformance.jsonl | 34 +- src/ai/tools.zig | 2 +- src/ai/transparency.zig | 2 +- src/audit/main.zig | 1534 +++++++++++++++++ src/cli/main.zig | 1466 +--------------- src/daemon/interface.zig | 79 + src/root.zig | 4 + 16 files changed, 1705 insertions(+), 1501 deletions(-) create mode 100644 src/audit/main.zig create mode 100644 src/daemon/interface.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd537d6..0439c83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Check the product's own text run: | - ./zig-out/bin/zag lint README.md AGENTS.md docs/architecture.md \ + ./zig-out/bin/zag-audit lint README.md AGENTS.md docs/architecture.md \ docs/standards-conformance.md docs/plain-language.md \ docs/competitive-position.md docs/roadmap.md @@ -70,7 +70,7 @@ jobs: - name: Write the conformance statement, and check it matches what is committed run: | - ./zig-out/bin/zag evidence + ./zig-out/bin/zag-audit evidence # The statement records when it was produced, so that field differs on # every run. Everything else must not: the results, the subjects and # the hashes of the artefacts they are about are what the committed @@ -80,15 +80,15 @@ jobs: strip_time < evidence/conformance.jsonl > /tmp/produced.jsonl git show HEAD:evidence/conformance.jsonl | strip_time > /tmp/committed.jsonl if ! diff -u /tmp/committed.jsonl /tmp/produced.jsonl; then - echo "The conformance statement has changed. Run './zig-out/bin/zag evidence' and commit the result." + echo "The conformance statement has changed. Run './zig-out/bin/zag-audit evidence' and commit the result." exit 1 fi - name: Show the accessibility statement - run: ./zig-out/bin/zag accessibility + run: ./zig-out/bin/zag-audit accessibility - name: Show the lifecycle record - run: ./zig-out/bin/zag lifecycle + run: ./zig-out/bin/zag-audit lifecycle cross-compile: name: compile ${{ matrix.target }} diff --git a/README.md b/README.md index 481fe2b..045fa90 100644 --- a/README.md +++ b/README.md @@ -72,27 +72,35 @@ zag ask --stream "..." # the same, printed as the answer arrives zag secrets .env # what would be taken out before anything is recorded zag why .workspace/events.jsonl # what a failure depended on, and what it affected zag run -- zig build test # run a command and record it as a block -zag lint README.md # check text against the plain-language rules -zag terms workspace # what a word means here -zag standards # the standards in force, and their editions -zag check # audit this repository -zag card model # the model card, generated from the system -zag accessibility # the accessibility statement and the themes + zag shell-hook bash # the shell integration to add to your shell zag workflow # this repository's own workflow, as a task graph -zag lifecycle # the lifecycle record, and what has not started -zag evidence # the conformance statement, from recorded evidence zag history "status:failed zig" # search recorded work, not a text file zag knowledge # the knowledge under .workspace/, and what is overdue zag objects --root . # audit stored command output without changing it zag recover --root . # inspect a damaged log and print a recovery plan ``` -Settings live in `.workspace/settings.toml`. `zag emit` writes an example with +Settings live in `.workspace/settings.toml`. `zag-audit emit` writes an example with every value at its default. A setting this build cannot read is reported by name, with what was found and what is being used instead, and the terminal opens anyway — a mistake in that file never costs you a shell. +`zag-audit` is the second binary. It checks a repository against the standards +it claims to meet, and nothing it does is needed to record work: + +``` +zag-audit check # audit this repository; the release gate +zag-audit lint README.md # check text against the plain-language rules +zag-audit terms workspace # what a word means here +zag-audit standards # the standards in force, and their editions +zag-audit emit # regenerate the schemas, vocabularies and docs +zag-audit evidence # the conformance statement, from recorded evidence +zag-audit card model # the model card, generated from the system +zag-audit accessibility # the accessibility statement and the themes +zag-audit lifecycle # the lifecycle record, and what has not started +``` + `zagd` holds a workspace open for clients, remote people and background agents: ``` diff --git a/build.zig b/build.zig index 1b22f90..cf1cafc 100644 --- a/build.zig +++ b/build.zig @@ -25,18 +25,29 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .imports = &.{.{ .name = "zag", .module = zag }}, }); - // The standards registry is data, and it ships inside the binary so that - // `zag standards` and `zag check` work from any directory. - cli_module.addAnonymousImport("registry_language", .{ .root_source_file = b.path("standards/registry/language.toml") }); - cli_module.addAnonymousImport("registry_accessibility", .{ .root_source_file = b.path("standards/registry/accessibility.toml") }); - cli_module.addAnonymousImport("registry_knowledge", .{ .root_source_file = b.path("standards/registry/knowledge.toml") }); - cli_module.addAnonymousImport("registry_interoperability", .{ .root_source_file = b.path("standards/registry/interoperability.toml") }); - cli_module.addAnonymousImport("registry_governance", .{ .root_source_file = b.path("standards/registry/governance.toml") }); - cli_module.addAnonymousImport("registry_requirements", .{ .root_source_file = b.path("standards/registry/requirements.toml") }); const cli = b.addExecutable(.{ .name = "zag", .root_module = cli_module }); b.installArtifact(cli); + // ---- Repository audit tool ------------------------------------------- + // Split out of `zag` so the workbench binary does not carry the standards + // registry, the plain-language rules or the conformance generator. The + // registry data ships here now, because these are the commands that read it. + const audit_module = b.createModule(.{ + .root_source_file = b.path("src/audit/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "zag", .module = zag }}, + }); + audit_module.addAnonymousImport("registry_language", .{ .root_source_file = b.path("standards/registry/language.toml") }); + audit_module.addAnonymousImport("registry_accessibility", .{ .root_source_file = b.path("standards/registry/accessibility.toml") }); + audit_module.addAnonymousImport("registry_knowledge", .{ .root_source_file = b.path("standards/registry/knowledge.toml") }); + audit_module.addAnonymousImport("registry_interoperability", .{ .root_source_file = b.path("standards/registry/interoperability.toml") }); + audit_module.addAnonymousImport("registry_governance", .{ .root_source_file = b.path("standards/registry/governance.toml") }); + audit_module.addAnonymousImport("registry_requirements", .{ .root_source_file = b.path("standards/registry/requirements.toml") }); + const audit = b.addExecutable(.{ .name = "zag-audit", .root_module = audit_module }); + b.installArtifact(audit); + // ---- Workspace daemon ------------------------------------------------ const daemon = b.addExecutable(.{ .name = "zagd", @@ -79,9 +90,13 @@ pub fn build(b: *std.Build) void { const daemon_tests = b.addTest(.{ .root_module = daemon.root_module }); const run_daemon_tests = b.addRunArtifact(daemon_tests); - const test_step = b.step("test", "Run the library, command-line and daemon tests"); + const audit_tests = b.addTest(.{ .root_module = audit.root_module }); + const run_audit_tests = b.addRunArtifact(audit_tests); + + const test_step = b.step("test", "Run the library, command-line, audit and daemon tests"); test_step.dependOn(&run_lib_tests.step); test_step.dependOn(&run_cli_tests.step); + test_step.dependOn(&run_audit_tests.step); test_step.dependOn(&run_daemon_tests.step); // ---- Conformance gate ------------------------------------------------ @@ -89,7 +104,7 @@ pub fn build(b: *std.Build) void { // the tool audit the repository's own documentation, standards registry and // metadata. A build that cannot state its own conformance evidence fails. const fmt = b.addSystemCommand(&.{ b.graph.zig_exe, "fmt", "--check", "build.zig", "src" }); - const self_check = b.addRunArtifact(cli); + const self_check = b.addRunArtifact(audit); self_check.addArgs(&.{ "check", "--repo", "." }); self_check.step.dependOn(test_step); const check_step = b.step("check", "Run tests, then audit the repository against its standards profile"); @@ -97,7 +112,7 @@ pub fn build(b: *std.Build) void { check_step.dependOn(&self_check.step); // ---- Generated interchange artefacts --------------------------------- - const emit = b.addRunArtifact(cli); + const emit = b.addRunArtifact(audit); emit.addArgs(&.{ "emit", "--out", "." }); const emit_step = b.step("emit", "Regenerate schemas, vocabularies and the OpenAPI description"); emit_step.dependOn(&emit.step); diff --git a/docs/adr/0006-generated-not-maintained.md b/docs/adr/0006-generated-not-maintained.md index 2602522..22bcfdc 100644 --- a/docs/adr/0006-generated-not-maintained.md +++ b/docs/adr/0006-generated-not-maintained.md @@ -11,7 +11,7 @@ model cards all describe the system. Where should they live? ## The decision They are generated from the code and the data that already exist, by -`zag emit`. None of them is maintained by hand. +`zag-audit emit`. None of them is maintained by hand. ## Why diff --git a/docs/adr/0008-knowledge-lives-in-the-workspace.md b/docs/adr/0008-knowledge-lives-in-the-workspace.md index 958d997..eefc236 100644 --- a/docs/adr/0008-knowledge-lives-in-the-workspace.md +++ b/docs/adr/0008-knowledge-lives-in-the-workspace.md @@ -28,7 +28,7 @@ than the entries that use a word. ## What follows - An agent is offered only knowledge that is approved and is not overdue. -- `zag check` audits `.workspace/` like anything else, and `zag evidence` +- `zag-audit check` audits `.workspace/` like anything else, and `zag-audit evidence` records what that audit established. - This repository keeps its own rules there, so the product is its own first user. diff --git a/docs/architecture.md b/docs/architecture.md index 172bdbe..33bde99 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -220,7 +220,7 @@ knowledge that is approved and is not overdue. An entry links to concepts rather than to words, so a search finds the entries about a concept, not the ones that happen to use the term. This repository keeps -its own rules there, and `zag check` audits them like anything else. +its own rules there, and `zag-audit check` audits them like anything else. ### The standards control plane diff --git a/docs/glossary.md b/docs/glossary.md index e6d61a4..d469fc1 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,6 +1,6 @@ # Glossary -Generated by `zag emit` from the concept system in `src/knowledge`. Do not +Generated by `zag-audit emit` from the concept system in `src/knowledge`. Do not edit this file: change the concept, and the definition changes everywhere it is used, including in the product's own text checks. diff --git a/docs/risk-register.md b/docs/risk-register.md index ef0f516..1b88912 100644 --- a/docs/risk-register.md +++ b/docs/risk-register.md @@ -1,6 +1,6 @@ # Risk register -Generated by `zag emit` from `src/ai/risk.zig`. A treatment that claims to +Generated by `zag-audit emit` from `src/ai/risk.zig`. A treatment that claims to reduce a risk must name a control that is enforced by code; an instruction to a model is not a control, and the build says so. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a4d115..c46c1c4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -106,7 +106,7 @@ directories. An agent run had no finish at all, so a workspace of completed runs displayed every one of them as still going. Two more were claims rather than records. The daemon said it held a workspace -open for clients, and it has no socket. `zag check` never loaded the requirements +open for clients, and it has no socket. `zag-audit check` never loaded the requirements register. Every profile naming a requirement was therefore checked against an empty set, and eleven dangling identifiers went unnoticed. diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 7aaac37..3cb7e74 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/tools.zig b/src/ai/tools.zig index f988168..db78375 100644 --- a/src/ai/tools.zig +++ b/src/ai/tools.zig @@ -5,7 +5,7 @@ //! policy engine can decide it, the log can record it, and a person can read it //! back later and know what happened. //! -//! The types are also the schema: `zag emit` publishes them as JSON Schema, so +//! The types are also the schema: `zag-audit emit` publishes them as JSON Schema, so //! a provider, a plugin or another implementation sees the same contract. const std = @import("std"); diff --git a/src/ai/transparency.zig b/src/ai/transparency.zig index d7e8bd3..0694b2b 100644 --- a/src/ai/transparency.zig +++ b/src/ai/transparency.zig @@ -215,7 +215,7 @@ test "unmet obligations are listed rather than hidden" { const arena = arena_state.allocator(); // Every obligation is met today, and each one says where. When one is not, - // it appears here with its gap, and `zag check` counts it as a problem — + // it appears here with its gap, and `zag-audit check` counts it as a problem — // it used to print the gap and leave the count at zero, so the build stayed // green while listing what it had not done. const unmet = try unmetObligations(arena); diff --git a/src/audit/main.zig b/src/audit/main.zig new file mode 100644 index 0000000..b1f783d --- /dev/null +++ b/src/audit/main.zig @@ -0,0 +1,1534 @@ +//! `zag-audit` — the tool that checks this repository against its own claims. +//! +//! These commands were in `zag` itself, which made the workbench binary carry +//! the standards registry, the plain-language rules, the concept system and the +//! conformance evidence generator. None of that is needed to record a shell +//! session or ask a model a question, and a person opening a terminal should +//! not have to scroll past thirteen commands that audit documentation to find +//! the one that opens a shell. +//! +//! The split is by what a command looks at. `zag` looks at a workspace: its +//! record, its policy, its shell, its history. `zag-audit` looks at a +//! repository: its text, its standards, its generated files, its evidence. +//! Nothing was rewritten in the move. +//! +//! `zag check` is the release gate and is still the release gate; it is just +//! spelled `zag-audit check` now, and `zig build check` runs it either way. + +const std = @import("std"); +const zag = @import("zag"); + +const Command = enum { + help, + version, + lint, + terms, + standards, + check, + emit, + card, + risk, + impact, + quality, + accessibility, + report, + lifecycle, + evidence, + + fn parse(text: []const u8) ?Command { + const table = [_]struct { name: []const u8, command: Command }{ + .{ .name = "help", .command = .help }, + .{ .name = "--help", .command = .help }, + .{ .name = "-h", .command = .help }, + .{ .name = "version", .command = .version }, + .{ .name = "--version", .command = .version }, + .{ .name = "lint", .command = .lint }, + .{ .name = "terms", .command = .terms }, + .{ .name = "standards", .command = .standards }, + .{ .name = "check", .command = .check }, + .{ .name = "emit", .command = .emit }, + .{ .name = "card", .command = .card }, + .{ .name = "risk", .command = .risk }, + .{ .name = "impact", .command = .impact }, + .{ .name = "quality", .command = .quality }, + .{ .name = "accessibility", .command = .accessibility }, + .{ .name = "report", .command = .report }, + .{ .name = "lifecycle", .command = .lifecycle }, + .{ .name = "evidence", .command = .evidence }, + }; + for (table) |entry| { + if (std.mem.eql(u8, text, entry.name)) return entry.command; + } + return null; + } +}; + +pub const help_text = + \\zag-audit - check a repository against the standards it claims to meet. + \\ + \\Use it like this: + \\ zag-audit [options] + \\ + \\Commands + \\ check Audit this repository against its standards profile. + \\ lint Check text against the plain-language rules. + \\ terms Show the registered concepts and their definitions. + \\ standards Show the standards in force and how each is checked. + \\ emit Write the schemas, the vocabulary and the interface files. + \\ evidence Write the conformance statement from recorded evidence. + \\ card Write the model card, system card, datasheet or fact sheet. + \\ risk Show the risk register. + \\ impact Show the impact assessment. + \\ quality Show the quality measures, including what was never measured. + \\ accessibility Show the accessibility statement and check the themes. + \\ lifecycle Show the lifecycle record, and what has not started. + \\ report Show an example report in the product's notation. + \\ + \\Options + \\ --profile Use this conformance profile. The default is "default". + \\ --kind Say what the text is, so the right rules apply. + \\ --audience Say who the text is written for. + \\ --json Write the result as JSON. + \\ --out Write generated files here. + \\ --repo Audit this directory. + \\ --root Read the workspace in this directory. + \\ + \\To record work rather than audit it, use "zag". Run "zag help" to see it. + \\ +; + +const Options = struct { + profile: []const u8 = "default", + kind: []const u8 = "body", + audience: []const u8 = "developer", + json: bool = false, + out: []const u8 = ".", + repo: []const u8 = ".", + root: []const u8 = ".", + positional: []const []const u8 = &.{}, +}; + +fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { + var options: Options = .{}; + var positional: std.ArrayList([]const u8) = .empty; + + var index: usize = 0; + while (index < args.len) : (index += 1) { + const arg = args[index]; + if (std.mem.eql(u8, arg, "--json")) { + options.json = true; + continue; + } + const named = [_]struct { flag: []const u8, field: *[]const u8 }{ + .{ .flag = "--profile", .field = &options.profile }, + .{ .flag = "--kind", .field = &options.kind }, + .{ .flag = "--audience", .field = &options.audience }, + .{ .flag = "--out", .field = &options.out }, + .{ .flag = "--repo", .field = &options.repo }, + .{ .flag = "--root", .field = &options.root }, + }; + var matched = false; + for (named) |entry| { + if (!std.mem.eql(u8, arg, entry.flag)) continue; + index += 1; + if (index >= args.len) return error.MissingOptionValue; + entry.field.* = args[index]; + matched = true; + break; + } + if (matched) continue; + if (std.mem.startsWith(u8, arg, "--")) return error.UnknownOption; + try positional.append(arena, arg); + } + + options.positional = try positional.toOwnedSlice(arena); + return options; +} + +pub fn main(init: std.process.Init) !u8 { + const gpa = init.gpa; + const io = init.io; + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const argv = try init.minimal.args.toSlice(arena); + + var out_buffer: [64 * 1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(io, &out_buffer); + const w = &stdout.interface; + + if (argv.len < 2) { + try w.writeAll(help_text); + try w.flush(); + return 0; + } + + const command = Command.parse(argv[1]) orelse { + try w.print("\"{s}\" is not a zag-audit command.\n\n", .{argv[1]}); + try w.writeAll(help_text); + try w.flush(); + return 2; + }; + + var text_args: std.ArrayList([]const u8) = .empty; + for (argv[2..]) |arg| try text_args.append(arena, arg); + + const options = parseOptions(arena, text_args.items) catch |err| { + switch (err) { + error.MissingOptionValue => try w.writeAll("An option is missing its value. Run \"zag-audit help\" to see the options.\n"), + error.UnknownOption => try w.writeAll("That option is not one zag-audit knows. Run \"zag-audit help\" to see the options.\n"), + else => return err, + } + try w.flush(); + return 2; + }; + + const status = try run(arena, io, w, command, options); + try w.flush(); + return status; +} + +fn run( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + command: Command, + options: Options, +) !u8 { + return switch (command) { + .help => blk: { + try w.writeAll(help_text); + break :blk 0; + }, + .version => blk: { + try w.print("zag-audit {s}\n", .{zag.version}); + break :blk 0; + }, + .lint => try lint(arena, io, w, options), + .terms => try terms(arena, w, options), + .standards => try standards(arena, w, options), + .check => try check(arena, io, w, options), + .emit => try emit(arena, io, w, options), + .card => try card(arena, w, options), + .risk => try riskReport(arena, w), + .impact => try impactReport(arena, w), + .quality => try qualityReport(arena, w), + .accessibility => try accessibilityReport(arena, w), + .report => try exampleReport(arena, w), + .lifecycle => try lifecycleReport(arena, w), + .evidence => try evidenceReport(arena, io, w, options), + }; +} + +/// The actor the tool acts as when it opens a workspace of its own accord. +/// +/// This is the workbench doing its job, not a person asking for something, and +/// the record has to be able to tell those apart. +fn workbenchActor() zag.events.event.Actor { + return zag.events.event.Actor.of(zag.core.identity.program("zag-audit")); +} + +fn wallClock(io: std.Io) zag.core.time.Timestamp { + const raw = std.Io.Timestamp.now(io, .real); + return .{ .ns = @intCast(raw.nanoseconds) }; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +fn lint(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { + if (options.positional.len == 0) { + try w.writeAll("Name at least one file to check. For example: zag lint README.md\n"); + return 2; + } + + const kind = std.meta.stringToEnum(zag.language.audience.TextKind, options.kind) orelse { + try w.print("\"{s}\" is not a kind of text zag knows. Try body, error_message, permission_prompt, button_label, procedure or warning.\n", .{options.kind}); + return 2; + }; + const who = zag.language.audience.byId(options.audience) orelse { + try w.print("\"{s}\" is not an audience zag knows. Try developer, new-user, operator-in-incident, auditor, easy-read, legal-reviewer or scientific-reviewer.\n", .{options.audience}); + return 2; + }; + + const system = try zag.knowledge.vocabulary.build(arena); + var worst: u8 = 0; + + for (options.positional) |path| { + const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(8 << 20)) catch { + try w.print("zag could not read {s}.\n", .{path}); + worst = 1; + continue; + }; + const report = try zag.language.plain.check(arena, source, .{ + .kind = kind, + .audience = who, + .purpose = if (kind == .procedure) .instruct else .inform, + .terminology = &system, + }); + + if (options.json) { + try report.writeJson(w); + try w.writeAll("\n"); + } else { + try w.print("{s}\n", .{path}); + for (report.findings) |finding| try finding.writeLine(w); + try w.print(" {d} finding(s). Written for: {s}\n", .{ report.findings.len, who.description }); + try w.print(" Estimated reading: {s}.\n\n", .{report.metrics.describe()}); + } + for (report.findings) |finding| { + if (finding.severity == .blocking) worst = 1; + } + } + + if (!options.json) try w.print("{s}\n", .{zag.language.plain.limits_statement}); + return worst; +} + +fn terms(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 { + const system = try zag.knowledge.vocabulary.build(arena); + + if (options.positional.len == 0) { + const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); + try thesaurus.writeAlphabetical(w); + return 0; + } + + const query = options.positional[0]; + const resolution = system.resolve(query) orelse { + try w.print("\"{s}\" is not a registered term.\n", .{query}); + return 1; + }; + const concept = resolution.concept; + try w.print("{s}\n", .{concept.preferred}); + if (resolution.status == .deprecated) { + try w.print(" You wrote \"{s}\", which we no longer use. Use \"{s}\".\n", .{ resolution.matched, concept.preferred }); + } + try w.print(" Definition: {s}\n", .{concept.definition}); + try w.print(" Subject field: {s}\n", .{concept.subject_field}); + if (concept.source) |source| try w.print(" From: {s}\n", .{source}); + for (concept.designations) |designation| { + try w.print(" {s}: {s}\n", .{ switch (designation.status) { + .preferred => "Preferred", + .admitted => "Also called", + .deprecated => "Do not use", + }, designation.text }); + } + const path = try system.broaderPath(concept.id); + if (path.items.len > 0) { + try w.writeAll(" Broader: "); + for (path.items, 0..) |item, i| { + if (i > 0) try w.writeAll(" -> "); + try w.writeAll(system.get(item).?.preferred); + } + try w.writeAll("\n"); + } + const children = try system.narrower(concept.id); + if (children.items.len > 0) { + try w.writeAll(" Narrower: "); + for (children.items, 0..) |item, i| { + if (i > 0) try w.writeAll(", "); + try w.writeAll(system.get(item).?.preferred); + } + try w.writeAll("\n"); + } + return 0; +} + +fn standards(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 { + if (options.positional.len > 0 and std.mem.eql(u8, options.positional[0], "profiles")) { + for (zag.standards.profile.registry) |profile| { + try w.print("{s} {s}\n {s}\n", .{ profile.id, profile.name, profile.description }); + try w.print(" Language rules: {s}. Written for: {s}. Accessibility: level {s}.\n", .{ + @tagName(profile.language), + profile.audience.description, + @tagName(profile.accessibility), + }); + try w.print(" Standards in force: {d}\n", .{profile.standards.len}); + for (profile.exclusions) |exclusion| { + try w.print(" Does not apply {s}: {s}\n", .{ exclusion.subject.text(), exclusion.reason }); + } + try w.writeAll("\n"); + } + return 0; + } + if (options.positional.len > 0 and std.mem.eql(u8, options.positional[0], "crosswalks")) { + try zag.standards.crosswalk.all.writeReport(w); + return 0; + } + + const registry = try loadStandards(arena); + const issues = try registry.validate(); + + var it = registry.standards.iterator(); + while (it.next()) |entry| { + const standard = entry.value_ptr.*; + try w.print("{s}", .{standard.identifier}); + if (standard.edition) |edition| try w.print(":{s}", .{edition}); + try w.print(" [{s}, {s}]\n {s}\n", .{ @tagName(standard.status), @tagName(standard.enforcement), standard.title }); + if (standard.purpose.len > 0) try w.print(" {s}\n", .{standard.purpose}); + } + + try w.print("\n{d} standards recorded.\n", .{registry.count()}); + if (issues.items.len > 0) { + try w.writeAll("\nProblems with the registry itself:\n"); + for (issues.items) |issue| try w.print(" {s} {s}\n", .{ issue.code.text(), issue.message }); + return 1; + } + return 0; +} + +fn loadStandards(arena: std.mem.Allocator) !zag.standards.registry.Registry { + var registry = zag.standards.registry.Registry.init(arena); + inline for (.{ + @embedFile("registry_language"), + @embedFile("registry_accessibility"), + @embedFile("registry_knowledge"), + @embedFile("registry_interoperability"), + @embedFile("registry_governance"), + }) |source| { + _ = try registry.loadStandards(source); + } + return registry; +} + +fn card(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 { + const facts = try workbenchFacts(arena); + const kind = if (options.positional.len > 0) options.positional[0] else "model"; + + if (std.mem.eql(u8, kind, "model")) { + try zag.ai.documentation.writeModelCard(facts, w); + } else if (std.mem.eql(u8, kind, "system")) { + try zag.ai.documentation.writeSystemCard(facts, w); + } else if (std.mem.eql(u8, kind, "factsheet")) { + try zag.ai.documentation.writeFactSheet(facts, w); + } else if (std.mem.eql(u8, kind, "datasheet")) { + try zag.ai.documentation.writeDatasheet(historyDataset(), w); + } else { + try w.print("\"{s}\" is not a card zag writes. Try model, system, factsheet or datasheet.\n", .{kind}); + return 2; + } + return 0; +} + +fn workbenchFacts(arena: std.mem.Allocator) !zag.ai.documentation.SystemFacts { + var suite = try zag.ai.evaluation.workbenchSuite(arena); + try suite.addResult(.{ + .measure_id = "M-PROMPT-CLARITY", + .value = 1.0, + .sample_size = 4, + .method = .automated_test, + .measured_at = try zag.core.time.Timestamp.parseIso("2026-09-04T12:00:00Z"), + .conditions = "The generated prompts in the approval test suite", + }); + try suite.addResult(.{ + .measure_id = "M-POLICY-CORRECTNESS", + .value = 1.0, + .sample_size = 24, + .method = .automated_test, + .measured_at = try zag.core.time.Timestamp.parseIso("2026-09-04T12:00:00Z"), + .conditions = "The policy test suite, including path traversal and expiry", + }); + + return .{ + .name = "zag workbench", + .version = zag.version, + .owner = "workbench maintainers", + .contact = "the repository issue tracker", + .license = "MIT", + .purpose = "Help a developer run commands, read and change code, and direct agents, on a machine they control.", + .intended_users = &.{"Software developers working on their own or their organisation's code."}, + .intended_uses = &.{ + "Running commands and reading their output as durable records.", + "Directing an agent to change code under a policy the person sets.", + "Reviewing what an agent did, and why.", + }, + .excluded_uses = &.{ + "Deciding anything about a person, such as employment, credit, benefits or access.", + "Running without a person able to see and stop what an agent does.", + }, + .model = .{ .provider = "configured by the operator", .model = "configured by the operator", .version = null }, + .model_inputs = &.{ + "The request the person typed.", + "Files the policy allows the agent to read.", + "Output of commands the agent ran.", + }, + .model_outputs = &.{ + "A plan, in words.", + "Typed tool requests, which the policy engine decides before anything runs.", + }, + .policy = try zag.ai.policy.repositoryWriteNoNetwork(".", arena), + .allowed_capabilities = &.{ .@"fs.read", .@"fs.write", .@"process.execute", .@"git.read", .@"git.commit" }, + .data_sources = &.{ + .{ + .name = "the workspace", + .description = "Files, commands and output in the repository the person opened.", + .personal_data = false, + .leaves_machine = false, + .retention = "Until the person deletes the workspace.", + }, + .{ + .name = "the event log", + .description = "Every typed event, chained by hash.", + .personal_data = false, + .leaves_machine = false, + .retention = "For as long as the retention rule on each record says.", + }, + }, + .limitations = &.{ + "It cannot tell whether a change an agent wrote is correct. It can only show you what changed and let you check.", + "Block boundaries depend on shell integration. Without it, timings and exit statuses are inferred and marked as inferred.", + "The graphical renderer and the language-server client are not built yet.", + }, + .human_oversight = "A person can stop any agent, refuse any approval, and withdraw any standing permission. Operations that cannot be undone always stop and ask.", + .evaluations = suite, + .risks = try zag.ai.risk.workbenchRegister(arena), + .impact = try zag.ai.impact.workbenchAssessment(arena), + .produced_at = try zag.core.time.Timestamp.parseIso("2026-09-04T00:00:00Z"), + .review_due = try zag.core.time.Timestamp.parseIso("2027-03-04T00:00:00Z"), + }; +} + +fn historyDataset() zag.ai.documentation.DatasetFacts { + return .{ + .name = "workspace command history", + .version = "2026-09", + .purpose = "Let a person search what they ran, where, and what happened.", + .made_by = "the workbench, from the person's own sessions", + .contents = "One record for each command: the text, the directory, the repository, the exit status and the time it took.", + .instance_count = 0, + .collection_method = "Recorded by the workbench as the person worked, from shell integration marks.", + .preparation = "Output larger than a few kilobytes is stored by hash rather than inline.", + .uses = &.{ "Searching earlier work.", "Explaining how a regression was introduced." }, + .unsupported_uses = &.{ "Judging how productive a person is.", "Training a model without the person's agreement." }, + .personal_data = true, + .consent_basis = "The person's own use of their own machine. Nothing is shared unless they share it.", + .retention = "Kept locally until the person deletes it. Nothing is sent anywhere by default.", + .limitations = &.{ + "Without shell integration, command boundaries are inferred and some records are incomplete.", + "Commands typed inside a full-screen program are not recorded separately.", + }, + .contact = "the repository issue tracker", + .produced_at = zag.core.time.Timestamp.epoch, + }; +} + +fn riskReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { + const register = try zag.ai.risk.workbenchRegister(arena); + try register.writeReport(w); + const findings = try register.review(); + return if (findings.items.len > 0) 1 else 0; +} + +fn impactReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { + const assessment = try zag.ai.impact.workbenchAssessment(arena); + try assessment.writeText(w); + const findings = try assessment.review(arena); + if (findings.items.len > 0) { + try w.writeAll("\nOpen items:\n"); + for (findings.items) |finding| try w.print(" - {s}\n", .{finding}); + return 1; + } + return 0; +} + +fn qualityReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { + const suite = try zag.ai.evaluation.workbenchSuite(arena); + try suite.writeReport(w); + return 0; +} + +fn accessibilityReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { + try zag.accessibility.wcag.writeStatement(w, .aa); + try w.writeAll("\n"); + try zag.accessibility.contrast.default_dark.writeReport(w, .aa); + try w.writeAll("\n"); + try zag.accessibility.contrast.default_light.writeReport(w, .aa); + + var status: u8 = 0; + inline for ([_]zag.accessibility.contrast.Theme{ + zag.accessibility.contrast.default_dark, + zag.accessibility.contrast.default_light, + }) |theme| { + const failures = try theme.check(arena, .aa); + if (failures.items.len > 0) status = 1; + } + + const tree_findings = try zag.accessibility.semantic_tree.check(arena, zag.accessibility.semantic_tree.exampleTree(), .{}); + try w.print("\nThe published accessibility tree has {d} problem(s).\n", .{tree_findings.items.len}); + for (tree_findings.items) |finding| try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); + if (tree_findings.items.len > 0) status = 1; + return status; +} + +fn exampleReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { + _ = arena; + const metrics = zag.reports.metrics; + const period: metrics.Period = .{ + .start = try zag.core.time.Timestamp.parseIso("2026-09-01T00:00:00Z"), + .end = try zag.core.time.Timestamp.parseIso("2026-09-08T00:00:00Z"), + }; + + const latency: metrics.Metric = .{ + .id = "latency", + .name = "Keystroke latency, 99th percentile", + .definition = "Time from a key press to the frame that shows it, on an idle machine.", + .kind = .quantile, + .unit = "ms", + .direction = .lower_is_better, + .source = "the render benchmark", + .decimals = 1, + }; + const commands: metrics.Metric = .{ + .id = "commands", + .name = "Commands run", + .definition = "Commands submitted through a session, including those an agent submitted.", + .kind = .flow, + .unit = "{command}", + .direction = .neutral, + .source = "the workspace event log", + }; + const denied: metrics.Metric = .{ + .id = "denied", + .name = "Tool calls denied by policy", + .definition = "Typed tool requests the policy refused, as a share of all tool requests.", + .kind = .rate, + .unit = "1", + .direction = .neutral, + .source = "the workspace event log", + .decimals = 2, + }; + + const table: zag.reports.notation.Table = .{ + .title = "Workspace, week of 1 September 2026", + .period = period, + .rows = &.{ + .{ .metric = latency, .value = .{ .metric_id = "latency", .value = 8.4, .period = period, .comparison = .{ .target = 10.0 }, .confidence = .sampled, .sample_size = 1000 } }, + .{ .metric = commands, .value = .{ .metric_id = "commands", .value = 412, .period = period, .comparison = .{ .previous_period = 388 } } }, + .{ .metric = denied, .value = .{ .metric_id = "denied", .value = 0.03, .period = period, .comparison = .{ .previous_period = 0.02 } } }, + }, + }; + try table.write(w); + + try w.writeAll("\n"); + const chart: zag.reports.notation.BarChart = .{ + .title = "Keystroke latency by build", + .metric = latency, + .bars = &.{ + .{ .label = "debug", .value = 18.2 }, + .{ .label = "release", .value = 8.4 }, + }, + }; + try chart.write(w); + return 0; +} + +fn lifecycleReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { + const record = zag.ai.lifecycle.workbenchRecord(); + try record.writeReport(arena, w); + const findings = try record.review(arena); + return if (findings.items.len > 0) 1 else 0; +} + +/// `zag evidence` — run the checks that can be run, record what each one +/// established, and write the conformance statement that follows from it. +/// Hash the bytes of the artefact a piece of evidence is about. +/// +/// Evidence used to carry `Hash.of("vocabulary")` — the hash of the word, not +/// of the thing. That is worse than no hash: it looks like a fingerprint, it is +/// stable across every change to the file, and it would let a record about one +/// version of an artefact be presented as a record about another. +/// +/// A file that cannot be read produces the zero hash, which is the honest +/// answer: the artefact was not fingerprinted, and a reader can tell. +fn artefactHash(arena: std.mem.Allocator, io: std.Io, path: []const u8) !zag.core.hash.Hash { + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 << 20)) catch { + return zag.core.hash.Hash.zero; + }; + return zag.core.hash.Hash.of(bytes); +} + +/// Hash the themes that were actually checked. +/// +/// The colours are compiled in, so there is no file to read; hashing their +/// values is what makes the record specific to the palette that passed. +fn themeHash(arena: std.mem.Allocator) !zag.core.hash.Hash { + var out: std.Io.Writer.Allocating = .init(arena); + inline for ([_]zag.accessibility.contrast.Theme{ + zag.accessibility.contrast.default_dark, + zag.accessibility.contrast.default_light, + }) |theme| { + try out.writer.writeAll(theme.name); + for (theme.pairs) |pair| { + try out.writer.print("|{s}:{x:0>2}{x:0>2}{x:0>2}:{x:0>2}{x:0>2}{x:0>2}", .{ + pair.name, + pair.foreground.r, + pair.foreground.g, + pair.foreground.b, + pair.background.r, + pair.background.g, + pair.background.b, + }); + } + } + return zag.core.hash.Hash.of(out.written()); +} + +fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { + const profile = zag.standards.profile.byId(options.profile) orelse { + try w.print("\"{s}\" is not a profile zag knows. Run \"zag standards profiles\" to see them.\n", .{options.profile}); + return 2; + }; + + var registry = try loadStandards(arena); + _ = try registry.loadRequirements(@embedFile("registry_requirements")); + + var ledger = zag.standards.evidence.Ledger.init(arena); + var ids: zag.core.id.Generator = .init(0x2026_0904, 1_788_000_000_000); + const tool: zag.standards.evidence.ToolIdentity = .{ .name = "zag-audit check", .version = zag.version }; + // The time the evidence was produced, from the clock. It used to be the + // epoch, so every record claimed to have been made on the first of January + // 1970 and nothing could be ordered or expired. + const now = wallClock(io); + + // Terminology. + const system = try zag.knowledge.vocabulary.build(arena); + const term_findings = try system.validate(); + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = "ISO-704:R-DEFINITION", + .subject = "src/knowledge/vocabulary.zig", + .method = .static_analysis, + .result = if (term_findings.items.len == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = try artefactHash(arena, io, "src/knowledge/vocabulary.zig"), + .note = "Every concept was checked for a definition that is present, not circular and not negative.", + }); + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = "ISO-704:R-ONE-TERM", + .subject = "src/knowledge/vocabulary.zig", + .method = .static_analysis, + .result = if (term_findings.items.len == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = try artefactHash(arena, io, "src/knowledge/vocabulary.zig"), + .note = "No preferred term designates two concepts.", + }); + + const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); + const reciprocity = try thesaurus.checkReciprocity(); + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = "ISO-25964:R-RECIPROCAL", + .subject = "the thesaurus view of the vocabulary", + .method = .static_analysis, + .result = if (reciprocity.items.len == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of("thesaurus"), + }); + + // Metadata. + const metadata_registry = try workbenchMetadataRegistry(arena); + const metadata_findings = try metadata_registry.validate(); + inline for (.{ "ISO-11179:R-ONE-NAME", "ISO-11179:R-VALUE-DOMAIN" }) |requirement_id| { + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = requirement_id, + .subject = "the metadata registry", + .method = .static_analysis, + .result = if (metadata_findings.items.len == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of("metadata"), + }); + } + + // Accessibility. + var contrast_failures: usize = 0; + inline for ([_]zag.accessibility.contrast.Theme{ + zag.accessibility.contrast.default_dark, + zag.accessibility.contrast.default_light, + }) |theme| { + contrast_failures += (try theme.check(arena, .aa)).items.len; + } + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = "WCAG-2.2:R-CONTRAST", + .subject = "the shipped themes", + .method = .automated_test, + .result = if (contrast_failures == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = try themeHash(arena), + }); + + // Target size and name-role-value are properties of a user interface. This + // build has no renderer, so there is no interface to measure them on. The + // checker runs against an example tree, and a pass against a fixture is + // evidence about the fixture: recording it as a pass for the product would + // be the exact thing the conformance rules forbid. It is recorded as not + // tested, with the reason, until there is something to test. + inline for (.{ "WCAG-2.2:R-TARGET-SIZE", "WCAG-2.2:R-NAME-ROLE-VALUE" }) |requirement_id| { + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = requirement_id, + .subject = "no user interface exists yet", + .method = .automated_test, + .result = .not_tested, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.zero, + .note = "This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see.", + }); + } + + // Governance. + const risk_register = try zag.ai.risk.workbenchRegister(arena); + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = "ISO-23894:R-TREATED", + .subject = "the risk register", + .method = .static_analysis, + .result = if ((try risk_register.review()).items.len == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = try artefactHash(arena, io, "src/ai/risk.zig"), + }); + const assessment = try zag.ai.impact.workbenchAssessment(arena); + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = "ISO-42005:R-AFFECTED", + .subject = "the impact assessment", + .method = .static_analysis, + .result = if ((try assessment.review(arena)).items.len == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of("impact"), + }); + + // Product text. + const help_report = try zag.language.plain.check(arena, help_text, .{ .kind = .body, .audience = profile.audience, .terminology = &system }); + var blocking: usize = 0; + for (help_report.findings) |finding| { + if (finding.severity == .blocking) blocking += 1; + } + // Each requirement answers for its own rule, not for "nothing blocking". + // + // Only two rules in the whole checker can produce a blocking finding, so + // "no blocking problem" was a much weaker statement than the `pass` beside + // it implied: a document could break sentence length, leave abbreviations + // undefined and label a button "OK", and still be recorded as passing four + // requirements. A machine reading the record sees the result, not the note. + // + // Two of these have a rule that checks them, so they are answered by that + // rule. The other two are about whether a reader can find and understand + // the text, which no checker establishes, so they are recorded as partly + // established and say what is missing. + const NarrowRule = struct { + requirement: []const u8, + rule: zag.language.plain.Rule, + }; + inline for ([_]NarrowRule{ + .{ .requirement = "ISO-24495-1:R-ERROR-NEXT-STEP", .rule = .error_without_next_step }, + .{ .requirement = "ISO-24495-1:R-LABEL-ACTION", .rule = .label_without_action }, + }) |checked| { + var breaches: usize = 0; + for (help_report.findings) |finding| { + if (finding.rule == checked.rule) breaches += 1; + } + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = checked.requirement, + .subject = "the product's own text and the tool's help", + .method = .static_analysis, + .result = if (breaches == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of(help_text), + .note = "A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems.", + }); + } + inline for (.{ "ISO-24495-1:R-UNDERSTANDABLE", "ISO-24495-1:R-FINDABLE" }) |requirement_id| { + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = requirement_id, + .subject = "the product's own text and the tool's help", + .method = .static_analysis, + .result = if (blocking == 0) .partial else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of(help_text), + .note = "A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done.", + }); + } + + // The workspace knowledge base, against ISO 30401 and ISO 10013. + const workspace = zag.workspace.service.Service.open(arena, io, .{ + .root = options.repo, + .actor = workbenchActor(), + .now = wallClock(io), + }) catch |err| { + try w.print("zag could not read workspace knowledge in {s}: {s}. Fix the path or permissions, then generate the evidence again. No evidence file was written.\n", .{ + options.repo, + @errorName(err), + }); + return 1; + }; + { + var missing_owner: usize = 0; + var overdue: usize = 0; + var stateless: usize = 0; + for (workspace.report.knowledgeFindings) |finding| { + switch (finding.code) { + .missing_owner => missing_owner += 1, + .review_overdue => overdue += 1, + .approved_without_review_date => stateless += 1, + else => {}, + } + } + const knowledge_checks = [_]struct { + requirement_id: []const u8, + failures: usize, + note: []const u8, + }{ + .{ + .requirement_id = "ISO-30401:R-OWNER", + .failures = missing_owner, + .note = "Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here.", + }, + .{ + .requirement_id = "ISO-30401:R-CURRENT", + .failures = overdue, + .note = "No approved entry is past its review date. Whether the content is still correct is established by review.", + }, + .{ + .requirement_id = "ISO-10013:R-STATE", + .failures = stateless, + .note = "Every entry carries a state, so a draft cannot be read as approved.", + }, + }; + for (knowledge_checks) |entry| { + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = entry.requirement_id, + .subject = "the knowledge base under .workspace/", + .method = .static_analysis, + .result = if (entry.failures == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of("workspace-knowledge"), + .note = entry.note, + }); + } + } + + // The statement, for each scope the profile covers. + const scopes = [_]zag.standards.registry.Scope{ .interface_text, .documentation, .terminology, .metadata, .user_interface, .ai_system, .records, .reporting, .data, .process }; + var worst: u8 = 0; + for (scopes) |scope| { + const summary = try ledger.summarise(registry, scope, profile.id); + if (summary.total == 0) continue; + try ledger.writeStatement(w, registry, scope, profile.id); + try w.writeAll("\n"); + if (summary.claim == .not_conformant) worst = 1; + } + + // The evidence itself is a record, so it is written rather than printed and + // forgotten. It is deterministic: the same repository produces the same + // file, and a change in the file means a change in what was established. + var jsonl: std.Io.Writer.Allocating = .init(arena); + for (ledger.items.items) |item| { + try std.json.Stringify.value(item, .{}, &jsonl.writer); + try jsonl.writer.writeByte('\n'); + } + try writeGenerated(io, std.Io.Dir.cwd(), arena, options.out, "evidence/conformance.jsonl", jsonl.written()); + try w.print("Evidence written to {s}/evidence/conformance.jsonl\n", .{options.out}); + return worst; +} + +// --------------------------------------------------------------------------- +// emit and check +// --------------------------------------------------------------------------- + +fn emit(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { + const dir = std.Io.Dir.cwd(); + var written: usize = 0; + + const schemas = [_]struct { path: []const u8, body: []const u8 }{ + .{ + .path = "schemas/workspace-event.schema.json", + .body = try zag.interop.jsonschema.documentAlloc(zag.events.event.Envelope, arena, .{ + .id = "https://zag.dev/schemas/workspace-event.schema.json", + .title = "Workspace event", + .description = "One entry in the workspace event log.", + }), + }, + .{ + .path = "schemas/tool-request.schema.json", + .body = try zag.interop.jsonschema.documentAlloc(zag.ai.tools.ToolRequest, arena, .{ + .id = "https://zag.dev/schemas/tool-request.schema.json", + .title = "Tool request", + .description = "Everything an agent can ask to do. Each request names one capability.", + }), + }, + .{ + .path = "schemas/block.schema.json", + .body = try zag.interop.jsonschema.documentAlloc(zag.workspace.block.Block, arena, .{ + .id = "https://zag.dev/schemas/block.schema.json", + .title = "Block", + .description = "One unit of work, with what caused it and what it produced.", + }), + }, + }; + for (schemas) |schema| { + try writeGenerated(io, dir, arena, options.out, schema.path, schema.body); + try w.print("wrote {s}\n", .{schema.path}); + written += 1; + } + + // The daemon's interface, generated from the same types. + const openapi_body = try zag.interop.openapi.documentAlloc(&zag.daemon.interface.daemon_operations, arena, .{ + .title = "zag workspace daemon", + .version = zag.version, + .summary = "Read and drive a workspace over HTTP.", + .server_url = "http://127.0.0.1:7717", + }); + try writeGenerated(io, dir, arena, options.out, "schemas/openapi.json", openapi_body); + try w.writeAll("wrote schemas/openapi.json\n"); + written += 1; + + // An example settings file, with every value at its default. Somebody + // starting one from nothing should not have to read Zig to find out what + // the settings are called. + var example_settings: std.Io.Writer.Allocating = .init(arena); + try zag.terminal.settings.writeExample(&example_settings.writer); + try writeGenerated(io, dir, arena, options.out, "docs/settings.example.toml", example_settings.written()); + try w.writeAll("wrote docs/settings.example.toml\n"); + written += 1; + + // The metadata registry, so other languages can adopt the same names. + var registry_text: std.Io.Writer.Allocating = .init(arena); + const registry = try workbenchMetadataRegistry(arena); + try registry.writeJson(®istry_text.writer); + try writeGenerated(io, dir, arena, options.out, "schemas/metadata-registry.json", registry_text.written()); + try w.writeAll("wrote schemas/metadata-registry.json\n"); + written += 1; + + // The vocabulary, in the two forms other systems ask for. + const system = try zag.knowledge.vocabulary.build(arena); + var graph = try zag.knowledge.skos.buildGraph(arena, system, zag.knowledge.skos.default_scheme); + + var turtle: std.Io.Writer.Allocating = .init(arena); + try graph.writeTurtle(&turtle.writer); + try writeGenerated(io, dir, arena, options.out, "vocab/workspace.ttl", turtle.written()); + try w.writeAll("wrote vocab/workspace.ttl\n"); + + var jsonld: std.Io.Writer.Allocating = .init(arena); + try graph.writeJsonLd(&jsonld.writer); + try writeGenerated(io, dir, arena, options.out, "vocab/workspace.jsonld", jsonld.written()); + try w.writeAll("wrote vocab/workspace.jsonld\n"); + + var alphabetical: std.Io.Writer.Allocating = .init(arena); + const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); + try thesaurus.writeAlphabetical(&alphabetical.writer); + try writeGenerated(io, dir, arena, options.out, "vocab/workspace.thesaurus.txt", alphabetical.written()); + try w.writeAll("wrote vocab/workspace.thesaurus.txt\n"); + + // The glossary and the risk register are generated, not maintained by + // hand, so they cannot fall behind the code they describe. + var glossary: std.Io.Writer.Allocating = .init(arena); + try glossary.writer.writeAll( + \\# Glossary + \\ + \\Generated by `zag-audit emit` from the concept system in `src/knowledge`. Do not + \\edit this file: change the concept, and the definition changes everywhere it + \\is used, including in the product's own text checks. + \\ + \\ + ); + var concept_it = system.concepts.iterator(); + while (concept_it.next()) |entry| { + const concept = entry.value_ptr.*; + try glossary.writer.print("## {s}\n\n{s}\n\n", .{ concept.preferred, concept.definition }); + for (concept.designations) |designation| { + switch (designation.status) { + .admitted => try glossary.writer.print("Also called: {s}\n\n", .{designation.text}), + .deprecated => try glossary.writer.print("Do not use: {s}\n\n", .{designation.text}), + .preferred => {}, + } + } + if (concept.source) |source| try glossary.writer.print("From: {s}\n\n", .{source}); + } + try writeGenerated(io, dir, arena, options.out, "docs/glossary.md", glossary.written()); + try w.writeAll("wrote docs/glossary.md\n"); + + var risks: std.Io.Writer.Allocating = .init(arena); + try risks.writer.writeAll( + \\# Risk register + \\ + \\Generated by `zag-audit emit` from `src/ai/risk.zig`. A treatment that claims to + \\reduce a risk must name a control that is enforced by code; an instruction to + \\a model is not a control, and the build says so. + \\ + \\ + ); + const register = try zag.ai.risk.workbenchRegister(arena); + try register.writeReport(&risks.writer); + try writeGenerated(io, dir, arena, options.out, "docs/risk-register.md", risks.written()); + try w.writeAll("wrote docs/risk-register.md\n"); + + // The machine-readable index for agents. + var llms: std.Io.Writer.Allocating = .init(arena); + try zag.agent_context.llms_txt.write(zag.agent_context.llms_txt.workbenchDocument(), &llms.writer); + try writeGenerated(io, dir, arena, options.out, "llms.txt", llms.written()); + try w.writeAll("wrote llms.txt\n"); + + return 0; +} + +fn writeGenerated( + io: std.Io, + dir: std.Io.Dir, + arena: std.mem.Allocator, + out_root: []const u8, + relative_path: []const u8, + body: []const u8, +) !void { + const full = try std.fs.path.join(arena, &.{ out_root, relative_path }); + if (std.fs.path.dirname(full)) |parent| { + try dir.createDirPath(io, parent); + } + try dir.writeFile(io, .{ .sub_path = full, .data = body }); +} + +fn workbenchMetadataRegistry(arena: std.mem.Allocator) !zag.metadata.registry.Registry { + var registry = zag.metadata.registry.Registry.init(arena); + + try registry.addConcept(.{ .id = "process.working-directory", .object_class = "process", .property = "working directory", .definition = "Directory a process resolves relative paths against.", .concept_id = "session" }); + try registry.addConcept(.{ .id = "process.exit-status", .object_class = "process", .property = "exit status", .definition = "Status a process returned when it finished.", .concept_id = "block" }); + try registry.addConcept(.{ .id = "event.instant", .object_class = "workspace event", .property = "instant", .definition = "The moment the event happened.", .concept_id = "workspace-event" }); + try registry.addConcept(.{ .id = "artifact.content-hash", .object_class = "artefact", .property = "content hash", .definition = "Hash of the content this record refers to.", .concept_id = "artifact" }); + try registry.addConcept(.{ .id = "capability.name", .object_class = "capability", .property = "name", .definition = "The capability an operation needs.", .concept_id = "capability" }); + + try registry.addDomain(.{ .id = "path", .definition = "Filesystem path.", .datatype = .path, .maximum_length = 4096 }); + try registry.addDomain(.{ .id = "exit-status", .definition = "Process exit status.", .datatype = .integer, .minimum = 0, .maximum = 255 }); + try registry.addDomain(.{ .id = "instant", .definition = "Point in time, in UTC.", .datatype = .timestamp }); + try registry.addDomain(.{ .id = "content-hash", .definition = "BLAKE3-256 hash in the workbench's canonical form.", .datatype = .content_hash }); + try registry.addDomain(.{ + .id = "capability-name", + .definition = "One of the capabilities the policy engine knows.", + .datatype = .code, + .permitted = &.{ + .{ .value = "fs.read", .meaning = "Read a file or list a directory" }, + .{ .value = "fs.write", .meaning = "Create or change a file" }, + .{ .value = "fs.delete", .meaning = "Delete a file or directory" }, + .{ .value = "process.execute", .meaning = "Start a process" }, + .{ .value = "network.connect", .meaning = "Open a network connection" }, + .{ .value = "git.commit", .meaning = "Record a commit" }, + .{ .value = "git.push", .meaning = "Send commits to a remote" }, + .{ .value = "model.infer", .meaning = "Send a request to a model provider" }, + }, + }); + + try registry.addElement(.{ + .id = "workingDirectory", + .concept_id = "process.working-directory", + .domain_id = "path", + .canonical_key = "workingDirectory", + .zig_type = "[]const u8", + .aliases = &.{ "cwd", "working_directory", "current_path", "directory" }, + .obligation = .conditional, + .status = .standardised, + .definition = "Directory the process resolves relative paths against.", + .example = "/home/user/zag", + }); + try registry.addElement(.{ + .id = "exitStatus", + .concept_id = "process.exit-status", + .domain_id = "exit-status", + .canonical_key = "exitStatus", + .zig_type = "?u8", + .aliases = &.{ "exit_code", "exitCode", "returncode", "status_code" }, + .obligation = .conditional, + .status = .standardised, + .definition = "Status the process returned when it finished.", + .example = "0", + }); + try registry.addElement(.{ + .id = "at", + .concept_id = "event.instant", + .domain_id = "instant", + .canonical_key = "at", + .zig_type = "Timestamp", + .aliases = &.{ "timestamp", "time", "ts", "occurred_at" }, + .obligation = .mandatory, + .status = .standardised, + .definition = "The moment the event happened, in UTC.", + .example = "2026-09-04T20:41:31.500Z", + }); + try registry.addElement(.{ + .id = "contentHash", + .concept_id = "artifact.content-hash", + .domain_id = "content-hash", + .canonical_key = "contentHash", + .zig_type = "Hash", + .aliases = &.{ "content_hash", "digest", "sha", "checksum" }, + .obligation = .mandatory, + .status = .standardised, + .definition = "Hash of the content this record refers to.", + .example = "b3:0000000000000000000000000000000000000000000000000000000000000000", + }); + try registry.addElement(.{ + .id = "capability", + .concept_id = "capability.name", + .domain_id = "capability-name", + .canonical_key = "capability", + .zig_type = "Capability", + .aliases = &.{ "permission", "scope", "grant" }, + .obligation = .mandatory, + .status = .standardised, + .definition = "The capability an operation needs.", + .example = "process.execute", + }); + + return registry; +} + +/// `zag check` — the release gate. +fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { + const profile = zag.standards.profile.byId(options.profile) orelse { + try w.print("\"{s}\" is not a profile zag knows. Run \"zag standards profiles\" to see them.\n", .{options.profile}); + return 2; + }; + + try w.print("Checking {s} against the \"{s}\" profile.\n\n", .{ options.repo, profile.name }); + + var problems: usize = 0; + const dir = std.Io.Dir.cwd(); + + // 1. The standards registry itself, and the requirements taken from it. + // + // The requirements were not loaded here at all, so anything checking a + // requirement identifier was checking against an empty set: every lookup + // failed, and nothing looked one up, so nothing noticed. + var registry = try loadStandards(arena); + const requirement_count = try registry.loadRequirements(@embedFile("registry_requirements")); + const issues = try registry.validate(); + try w.print("Standards registry: {d} standards and {d} requirements recorded, {d} problem(s).\n", .{ + registry.count(), + requirement_count, + issues.items.len, + }); + for (issues.items) |issue| { + try w.print(" {s} {s}\n", .{ issue.code.text(), issue.message }); + problems += 1; + } + + // 1a. Every requirement a profile names has to exist. + // + // `extra_requirements` and `exclusions` are lists of identifiers, and until + // now nothing read either one. A profile could switch on a requirement that + // did not exist, or exclude one with a typo in it, and the build would + // report nothing: the exclusion silently applied to nothing, so a + // requirement the author meant to skip was enforced, or one they meant to + // add was not. + for (zag.standards.profile.registry) |candidate| { + for (candidate.extra_requirements) |id| { + if (registry.requirements.get(id) == null) { + try w.print(" PROFILE {s} switches on {s}, which is not a requirement.\n", .{ candidate.id, id }); + problems += 1; + } + } + for (candidate.exclusions) |exclusion| { + const known = switch (exclusion.subject) { + .requirement => |id| registry.requirements.get(id) != null, + .language_rule => |code| zag.language.plain.Rule.byCode(code) != null, + }; + if (!known) { + try w.print(" PROFILE {s} excludes {s}, which is not a {s}.\n", .{ + candidate.id, + exclusion.subject.text(), + @tagName(exclusion.subject), + }); + problems += 1; + } + } + } + + // 1b. Any file a standard points at has to be there. One entry named a + // requirements file that had never existed; the field was parsed, never + // read, and never checked, so nothing noticed. + for (registry.standards.values()) |standard| { + const named = standard.requirements_file orelse continue; + const full = try std.fs.path.join(arena, &.{ options.repo, named }); + dir.access(io, full, .{}) catch { + try w.print(" REGISTRY {s} names {s}, which is not there.\n", .{ standard.identifier, named }); + problems += 1; + }; + } + + // 2. The vocabulary. + const system = try zag.knowledge.vocabulary.build(arena); + const term_findings = try system.validate(); + try w.print("Vocabulary: {d} concepts, {d} problem(s).\n", .{ system.count(), term_findings.items.len }); + for (term_findings.items) |finding| { + try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); + problems += 1; + } + + const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); + const reciprocity = try thesaurus.checkReciprocity(); + for (reciprocity.items) |problem| { + try w.print(" TERM {s}\n", .{problem}); + problems += 1; + } + + // 3. The metadata registry. + const metadata_registry = try workbenchMetadataRegistry(arena); + const metadata_findings = try metadata_registry.validate(); + try w.print("Metadata registry: {d} elements, {d} problem(s).\n", .{ metadata_registry.elements.count(), metadata_findings.items.len }); + for (metadata_findings.items) |finding| { + try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); + problems += 1; + } + + // 4. Accessibility. + var contrast_problems: usize = 0; + inline for ([_]zag.accessibility.contrast.Theme{ + zag.accessibility.contrast.default_dark, + zag.accessibility.contrast.default_light, + }) |theme| { + const failures = try theme.check(arena, .aa); + for (failures.items) |failure| { + try w.print(" CONTRAST {s} in {s}: {d:.2}:1, needs {d:.1}:1\n", .{ failure.pair.name, theme.name, failure.result.ratio, failure.result.required }); + contrast_problems += 1; + } + } + const tree_findings = try zag.accessibility.semantic_tree.check(arena, zag.accessibility.semantic_tree.exampleTree(), .{}); + try w.print("Accessibility: {d} contrast problem(s), {d} tree problem(s).\n", .{ contrast_problems, tree_findings.items.len }); + for (tree_findings.items) |finding| try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); + problems += contrast_problems + tree_findings.items.len; + + // 5. Risk, impact and transparency. + const risk_register = try zag.ai.risk.workbenchRegister(arena); + const risk_findings = try risk_register.review(); + const assessment = try zag.ai.impact.workbenchAssessment(arena); + const impact_findings = try assessment.review(arena); + try w.print("Governance: {d} risk problem(s), {d} impact problem(s).\n", .{ risk_findings.items.len, impact_findings.items.len }); + for (risk_findings.items) |finding| try w.print(" RISK {s}\n", .{finding.message}); + for (impact_findings.items) |finding| try w.print(" IMPACT {s}\n", .{finding}); + problems += risk_findings.items.len + impact_findings.items.len; + + // An obligation nobody has met is a problem, and printing it as OPEN while + // leaving the count at zero meant the build stayed green with a list of + // things it had just said were not done. A check that reports and does not + // fail is a check that stops being read. + const unmet = try zag.ai.transparency.unmetObligations(arena); + try w.print("Transparency: {d} obligation(s) not yet met.\n", .{unmet.items.len}); + for (unmet.items) |obligation| { + try w.print(" OPEN {s} {s}\n", .{ obligation.topic.question(), obligation.gap }); + problems += 1; + } + + // 6. The product's own text, including this tool's help. + var text_findings: usize = 0; + const documents = [_]struct { path: []const u8, kind: zag.language.audience.TextKind }{ + .{ .path = "README.md", .kind = .body }, + .{ .path = "AGENTS.md", .kind = .body }, + .{ .path = "docs/architecture.md", .kind = .body }, + .{ .path = "docs/standards-conformance.md", .kind = .body }, + .{ .path = "docs/plain-language.md", .kind = .body }, + .{ .path = "docs/competitive-position.md", .kind = .body }, + .{ .path = "docs/roadmap.md", .kind = .body }, + .{ .path = "docs/threat-model.md", .kind = .body }, + .{ .path = "docs/platform-support.md", .kind = .body }, + .{ .path = "docs/if-something-is-wrong.md", .kind = .body }, + .{ .path = "CONTRIBUTING.md", .kind = .body }, + .{ .path = "SECURITY.md", .kind = .body }, + }; + for (documents) |document| { + const path = try std.fs.path.join(arena, &.{ options.repo, document.path }); + const source = dir.readFileAlloc(io, path, arena, .limited(8 << 20)) catch |err| { + try w.print(" TEXT zag could not read {s}: {s}.\n", .{ document.path, @errorName(err) }); + text_findings += 1; + continue; + }; + const report = try zag.language.plain.check(arena, source, .{ + .kind = document.kind, + .audience = profile.audience, + .terminology = &system, + }); + for (report.findings) |finding| { + if (finding.severity != .blocking) continue; + try w.print(" {s} {s}:{d} {s}\n", .{ finding.rule.code(), document.path, finding.line, finding.message }); + text_findings += 1; + } + } + const help_report = try zag.language.plain.check(arena, help_text, .{ + .kind = .body, + .audience = zag.language.audience.new_user, + .terminology = &system, + }); + for (help_report.findings) |finding| { + if (finding.severity != .blocking) continue; + try w.print(" {s} zag help: {s}\n", .{ finding.rule.code(), finding.message }); + text_findings += 1; + } + try w.print("Product text: {d} blocking problem(s).\n", .{text_findings}); + problems += text_findings; + + // 7. The repository's own workflow and lifecycle record. + const workflow = try zag.workspace.workflow.buildAndCheck(arena, options.repo); + const workflow_findings = try workflow.validate(arena); + const lifecycle_findings = try zag.ai.lifecycle.workbenchRecord().review(arena); + try w.print("Workflow and lifecycle: {d} workflow problem(s), {d} lifecycle problem(s).\n", .{ workflow_findings.items.len, lifecycle_findings.items.len }); + for (workflow_findings.items) |finding| try w.print(" WORKFLOW {s}: {s}\n", .{ finding.node_id, finding.message }); + for (lifecycle_findings.items) |finding| try w.print(" LIFECYCLE {s}\n", .{finding}); + problems += workflow_findings.items.len + lifecycle_findings.items.len; + + // 7b. The workspace policy. A policy that does not parse allows nothing, + // which is safe but is never what the person who wrote it meant, so the + // build says so rather than letting it sit there doing nothing. + { + const policy_path = try std.fs.path.join(arena, &.{ options.repo, zag.ai.policy_file.default_path }); + if (dir.readFileAlloc(io, policy_path, arena, .limited(1 << 20))) |source| { + const result = try zag.ai.policy_file.parse(arena, source); + if (result.policy) |written| { + try w.print("Workspace policy: {d} rule(s), no problems.\n", .{written.rules.len}); + } else { + try w.print("Workspace policy: {d} problem(s). Nothing in the file is in force.\n", .{result.problems.len}); + for (result.problems) |problem| { + if (problem.rule) |number| { + try w.print(" POLICY rule {d}: {s}\n", .{ number, problem.message }); + } else { + try w.print(" POLICY {s}\n", .{problem.message}); + } + problems += 1; + } + } + } else |_| { + try w.writeAll("Workspace policy: none written, so the built-in one is in force.\n"); + } + } + + // 8. Instruction files. + const agents_path = try std.fs.path.join(arena, &.{ options.repo, "AGENTS.md" }); + if (dir.readFileAlloc(io, agents_path, arena, .limited(4 << 20))) |source| { + const document = try zag.agent_context.agents_md.parse(arena, options.repo, source); + try w.print("Instruction file: {d} instruction(s), {d} finding(s).\n", .{ document.instructions.len, document.findings.len }); + for (document.findings) |finding| { + try w.print(" {s} AGENTS.md:{d} {s}\n", .{ finding.code.text(), finding.line, finding.message }); + problems += 1; + } + } else |err| { + try w.print("Instruction file: zag could not read AGENTS.md: {s}.\n", .{@errorName(err)}); + problems += 1; + } + + // 9. The workspace knowledge base. ISO 30401 asks for named owners and for + // knowledge that is kept current; this is where the product answers for + // its own knowledge rather than only for its code. + const opened = zag.workspace.service.Service.open(arena, io, .{ + .root = options.repo, + .actor = workbenchActor(), + .now = wallClock(io), + }) catch |err| blk: { + try w.print("Workspace knowledge: zag could not open it: {s}.\n", .{@errorName(err)}); + problems += 1; + break :blk null; + }; + if (opened) |workspace| { + try w.print("Workspace knowledge: {d} entries, {d} problem(s).\n", .{ + workspace.report.knowledgeCount, + workspace.report.knowledgeFindings.len, + }); + for (workspace.report.knowledgeFindings) |finding| { + try w.print(" {s} {s}: {s}\n", .{ finding.code.text(), finding.path, finding.message }); + problems += 1; + } + if (!workspace.report.logIsHealthy()) { + try w.writeAll(" LOG The event log is incomplete or damaged.\n"); + problems += 1; + } + // Knowledge an agent reads is product text, so it is held to the same + // language rules as the documentation. + for (workspace.service.knowledge.entries.items) |entry| { + const entry_report = try zag.language.plain.check(arena, entry.body, .{ + .kind = .body, + .audience = profile.audience, + .terminology = &system, + }); + for (entry_report.findings) |finding| { + if (finding.severity != .blocking) continue; + try w.print(" {s} {s}:{d} {s}\n", .{ finding.rule.code(), entry.path, finding.line, finding.message }); + problems += 1; + } + } + } + + // The statement. + try w.writeAll("\n"); + if (problems == 0) { + try w.writeAll("Every check that a program can run has passed.\n"); + } else { + try w.print("{d} problem(s) found by checks a program can run.\n", .{problems}); + } + try w.writeAll( + \\ + \\What this does not establish: whether a reader can find, understand and use + \\the information; whether a person using a screen reader can finish a task; + \\whether the risk treatments work in practice. Those are settled by evaluating + \\the product with people, and by audit. This report says only what was checked. + \\ + ); + return if (problems == 0) 0 else 1; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "the metadata registry the tool publishes is valid" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const registry = try workbenchMetadataRegistry(arena); + const findings = try registry.validate(); + if (findings.items.len > 0) { + for (findings.items) |finding| std.debug.print("{s} {s}\n", .{ finding.code.text(), finding.message }); + } + try testing.expectEqual(@as(usize, 0), findings.items.len); + try testing.expectEqualStrings("workingDirectory", registry.canonicalKey("cwd").?); + try testing.expectEqualStrings("exitStatus", registry.canonicalKey("exit_code").?); + try testing.expectEqualStrings("at", registry.canonicalKey("timestamp").?); +} + +test "evidence generation never hides an unreadable workspace" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + const event_path = try std.fmt.allocPrint(arena, "{s}/.workspace/events.jsonl", .{root}); + try std.Io.Dir.cwd().createDirPath(io, event_path); + + var buffer: [2048]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + const status = try evidenceReport(arena, io, &writer, .{ .repo = root }); + try testing.expectEqual(@as(u8, 1), status); + try testing.expect(std.mem.indexOf(u8, writer.buffered(), "No evidence file was written") != null); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 4413181..7740b2e 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1,12 +1,13 @@ //! The zag command-line tool. //! -//! Every subsystem in the library has a way in from here, because a capability -//! nobody can reach is a capability that rots. The tool is also the release -//! gate: `zag check` audits this repository against its own standards profile -//! and fails the build when something that can be checked is wrong. +//! This is the workbench: it opens a workspace, records what happens in it, and +//! asks models questions through the policy. Everything that audits a +//! repository against the standards it claims to meet moved to `zag-audit`, +//! because none of it is needed to record a shell session. //! -//! The help text below is itself checked by `zag check`, using the product's -//! plain-language rules. If the help is unclear, the build says so. +//! The help text below is still checked by `zag-audit check`, using the +//! product's own plain-language rules. If the help is unclear, the build says +//! so — the check just runs from the other binary now. const std = @import("std"); const zag = @import("zag"); @@ -15,17 +16,6 @@ const Command = enum { help, version, doctor, - lint, - terms, - standards, - check, - emit, - card, - risk, - impact, - quality, - accessibility, - report, events, objects, recover, @@ -33,8 +23,6 @@ const Command = enum { run, shell_hook, workflow, - lifecycle, - evidence, history, knowledge, term, @@ -52,17 +40,6 @@ const Command = enum { .{ .name = "version", .command = .version }, .{ .name = "--version", .command = .version }, .{ .name = "doctor", .command = .doctor }, - .{ .name = "lint", .command = .lint }, - .{ .name = "terms", .command = .terms }, - .{ .name = "standards", .command = .standards }, - .{ .name = "check", .command = .check }, - .{ .name = "emit", .command = .emit }, - .{ .name = "card", .command = .card }, - .{ .name = "risk", .command = .risk }, - .{ .name = "impact", .command = .impact }, - .{ .name = "quality", .command = .quality }, - .{ .name = "accessibility", .command = .accessibility }, - .{ .name = "report", .command = .report }, .{ .name = "events", .command = .events }, .{ .name = "objects", .command = .objects }, .{ .name = "recover", .command = .recover }, @@ -70,8 +47,6 @@ const Command = enum { .{ .name = "run", .command = .run }, .{ .name = "shell-hook", .command = .shell_hook }, .{ .name = "workflow", .command = .workflow }, - .{ .name = "lifecycle", .command = .lifecycle }, - .{ .name = "evidence", .command = .evidence }, .{ .name = "history", .command = .history }, .{ .name = "knowledge", .command = .knowledge }, .{ .name = "term", .command = .term }, @@ -96,17 +71,6 @@ pub const help_text = \\ \\Commands \\ doctor Show what this build can do, and what it cannot. - \\ lint Check text against the plain-language rules. - \\ terms Show the registered concepts and their definitions. - \\ standards Show the standards in force and how each is checked. - \\ check Audit this repository against its standards profile. - \\ emit Write the schemas, the vocabulary and the interface files. - \\ card Write the model card, system card, datasheet or fact sheet. - \\ risk Show the risk register. - \\ impact Show the impact assessment. - \\ quality Show the quality measures, including what was never measured. - \\ accessibility Show the accessibility statement and check the themes. - \\ report Show an example report in the product's notation. \\ events Verify an event log and summarise what happened. \\ objects Audit every command-output object without changing it. \\ recover Plan recovery of a damaged event log without changing it. @@ -114,8 +78,6 @@ pub const help_text = \\ run -- Run a command on a real terminal and record it as a block. \\ shell-hook Print the shell integration for bash, zsh, fish or pwsh. \\ workflow Show this repository's own workflow as a task graph. - \\ lifecycle Show the lifecycle record, and what has not started. - \\ evidence Write the conformance statement from recorded evidence. \\ history Search recorded work. For example: status:failed zig \\ knowledge Show the knowledge under .workspace/, and what is overdue. \\ term Open a shell in a terminal that records what you do. @@ -126,12 +88,8 @@ pub const help_text = \\ secrets Show what would be taken out of these files before recording them. \\ \\Options - \\ --profile Use this conformance profile. The default is "default". - \\ --kind Say what the text is, so the right rules apply. - \\ --audience Say who the text is written for. \\ --json Write the result as JSON. - \\ --out Write generated files here. - \\ --repo Audit this directory. + \\ --repo Read the repository in this directory. \\ --root Read the workspace in this directory. \\ --provider Which connector to use. Run "zag providers" to see them. \\ --model Which model to ask for. @@ -140,16 +98,15 @@ pub const help_text = \\ --stream Print a model's answer as it arrives, not when it finishes. \\ --approve-truncate Apply the recovery plan after preserving the original log. \\ + \\To audit a repository against the standards it claims to meet, use + \\"zag-audit". Run "zag-audit help" to see it. + \\ \\Read more in README.md, or run "zag doctor" to see this build. \\ ; const Options = struct { - profile: []const u8 = "default", - kind: []const u8 = "body", - audience: []const u8 = "developer", json: bool = false, - out: []const u8 = ".", repo: []const u8 = ".", root: []const u8 = ".", /// Run the shell exactly as it is, with no generated init file. @@ -203,10 +160,6 @@ fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { continue; } const named = [_]struct { flag: []const u8, field: *[]const u8 }{ - .{ .flag = "--profile", .field = &options.profile }, - .{ .flag = "--kind", .field = &options.kind }, - .{ .flag = "--audience", .field = &options.audience }, - .{ .flag = "--out", .field = &options.out }, .{ .flag = "--repo", .field = &options.repo }, .{ .flag = "--root", .field = &options.root }, .{ .flag = "--provider", .field = &options.provider }, @@ -312,17 +265,7 @@ fn run( break :blk 0; }, .doctor => try doctor(arena, io, w, options), - .lint => try lint(arena, io, w, options), - .terms => try terms(arena, w, options), - .standards => try standards(arena, w, options), - .check => try check(arena, io, w, options), - .emit => try emit(arena, io, w, options), - .card => try card(arena, w, options), - .risk => try riskReport(arena, w), - .impact => try impactReport(arena, w), - .quality => try qualityReport(arena, w), - .accessibility => try accessibilityReport(arena, w), - .report => try exampleReport(arena, w), + .events => try events(arena, io, w, options), .objects => try contentObjects(arena, io, w, options), .recover => try recoverWorkspace(arena, io, w, options), @@ -330,8 +273,6 @@ fn run( .run => try runCommand(arena, io, w, options, environment), .shell_hook => try shellHook(w, options), .workflow => try workflowReport(arena, w, options), - .lifecycle => try lifecycleReport(arena, w), - .evidence => try evidenceReport(arena, io, w, options), .history => try historySearch(arena, io, w, options), .knowledge => try knowledgeIndex(arena, io, w, options), .term => try interactiveTerminal(arena, io, w, options, environment), @@ -397,393 +338,6 @@ fn doctor(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Opti return 0; } -fn lint(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { - if (options.positional.len == 0) { - try w.writeAll("Name at least one file to check. For example: zag lint README.md\n"); - return 2; - } - - const kind = std.meta.stringToEnum(zag.language.audience.TextKind, options.kind) orelse { - try w.print("\"{s}\" is not a kind of text zag knows. Try body, error_message, permission_prompt, button_label, procedure or warning.\n", .{options.kind}); - return 2; - }; - const who = zag.language.audience.byId(options.audience) orelse { - try w.print("\"{s}\" is not an audience zag knows. Try developer, new-user, operator-in-incident, auditor, easy-read, legal-reviewer or scientific-reviewer.\n", .{options.audience}); - return 2; - }; - - const system = try zag.knowledge.vocabulary.build(arena); - var worst: u8 = 0; - - for (options.positional) |path| { - const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(8 << 20)) catch { - try w.print("zag could not read {s}.\n", .{path}); - worst = 1; - continue; - }; - const report = try zag.language.plain.check(arena, source, .{ - .kind = kind, - .audience = who, - .purpose = if (kind == .procedure) .instruct else .inform, - .terminology = &system, - }); - - if (options.json) { - try report.writeJson(w); - try w.writeAll("\n"); - } else { - try w.print("{s}\n", .{path}); - for (report.findings) |finding| try finding.writeLine(w); - try w.print(" {d} finding(s). Written for: {s}\n", .{ report.findings.len, who.description }); - try w.print(" Estimated reading: {s}.\n\n", .{report.metrics.describe()}); - } - for (report.findings) |finding| { - if (finding.severity == .blocking) worst = 1; - } - } - - if (!options.json) try w.print("{s}\n", .{zag.language.plain.limits_statement}); - return worst; -} - -fn terms(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 { - const system = try zag.knowledge.vocabulary.build(arena); - - if (options.positional.len == 0) { - const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); - try thesaurus.writeAlphabetical(w); - return 0; - } - - const query = options.positional[0]; - const resolution = system.resolve(query) orelse { - try w.print("\"{s}\" is not a registered term.\n", .{query}); - return 1; - }; - const concept = resolution.concept; - try w.print("{s}\n", .{concept.preferred}); - if (resolution.status == .deprecated) { - try w.print(" You wrote \"{s}\", which we no longer use. Use \"{s}\".\n", .{ resolution.matched, concept.preferred }); - } - try w.print(" Definition: {s}\n", .{concept.definition}); - try w.print(" Subject field: {s}\n", .{concept.subject_field}); - if (concept.source) |source| try w.print(" From: {s}\n", .{source}); - for (concept.designations) |designation| { - try w.print(" {s}: {s}\n", .{ switch (designation.status) { - .preferred => "Preferred", - .admitted => "Also called", - .deprecated => "Do not use", - }, designation.text }); - } - const path = try system.broaderPath(concept.id); - if (path.items.len > 0) { - try w.writeAll(" Broader: "); - for (path.items, 0..) |item, i| { - if (i > 0) try w.writeAll(" -> "); - try w.writeAll(system.get(item).?.preferred); - } - try w.writeAll("\n"); - } - const children = try system.narrower(concept.id); - if (children.items.len > 0) { - try w.writeAll(" Narrower: "); - for (children.items, 0..) |item, i| { - if (i > 0) try w.writeAll(", "); - try w.writeAll(system.get(item).?.preferred); - } - try w.writeAll("\n"); - } - return 0; -} - -fn standards(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 { - if (options.positional.len > 0 and std.mem.eql(u8, options.positional[0], "profiles")) { - for (zag.standards.profile.registry) |profile| { - try w.print("{s} {s}\n {s}\n", .{ profile.id, profile.name, profile.description }); - try w.print(" Language rules: {s}. Written for: {s}. Accessibility: level {s}.\n", .{ - @tagName(profile.language), - profile.audience.description, - @tagName(profile.accessibility), - }); - try w.print(" Standards in force: {d}\n", .{profile.standards.len}); - for (profile.exclusions) |exclusion| { - try w.print(" Does not apply {s}: {s}\n", .{ exclusion.subject.text(), exclusion.reason }); - } - try w.writeAll("\n"); - } - return 0; - } - if (options.positional.len > 0 and std.mem.eql(u8, options.positional[0], "crosswalks")) { - try zag.standards.crosswalk.all.writeReport(w); - return 0; - } - - const registry = try loadStandards(arena); - const issues = try registry.validate(); - - var it = registry.standards.iterator(); - while (it.next()) |entry| { - const standard = entry.value_ptr.*; - try w.print("{s}", .{standard.identifier}); - if (standard.edition) |edition| try w.print(":{s}", .{edition}); - try w.print(" [{s}, {s}]\n {s}\n", .{ @tagName(standard.status), @tagName(standard.enforcement), standard.title }); - if (standard.purpose.len > 0) try w.print(" {s}\n", .{standard.purpose}); - } - - try w.print("\n{d} standards recorded.\n", .{registry.count()}); - if (issues.items.len > 0) { - try w.writeAll("\nProblems with the registry itself:\n"); - for (issues.items) |issue| try w.print(" {s} {s}\n", .{ issue.code.text(), issue.message }); - return 1; - } - return 0; -} - -fn loadStandards(arena: std.mem.Allocator) !zag.standards.registry.Registry { - var registry = zag.standards.registry.Registry.init(arena); - inline for (.{ - @embedFile("registry_language"), - @embedFile("registry_accessibility"), - @embedFile("registry_knowledge"), - @embedFile("registry_interoperability"), - @embedFile("registry_governance"), - }) |source| { - _ = try registry.loadStandards(source); - } - return registry; -} - -fn card(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 { - const facts = try workbenchFacts(arena); - const kind = if (options.positional.len > 0) options.positional[0] else "model"; - - if (std.mem.eql(u8, kind, "model")) { - try zag.ai.documentation.writeModelCard(facts, w); - } else if (std.mem.eql(u8, kind, "system")) { - try zag.ai.documentation.writeSystemCard(facts, w); - } else if (std.mem.eql(u8, kind, "factsheet")) { - try zag.ai.documentation.writeFactSheet(facts, w); - } else if (std.mem.eql(u8, kind, "datasheet")) { - try zag.ai.documentation.writeDatasheet(historyDataset(), w); - } else { - try w.print("\"{s}\" is not a card zag writes. Try model, system, factsheet or datasheet.\n", .{kind}); - return 2; - } - return 0; -} - -fn workbenchFacts(arena: std.mem.Allocator) !zag.ai.documentation.SystemFacts { - var suite = try zag.ai.evaluation.workbenchSuite(arena); - try suite.addResult(.{ - .measure_id = "M-PROMPT-CLARITY", - .value = 1.0, - .sample_size = 4, - .method = .automated_test, - .measured_at = try zag.core.time.Timestamp.parseIso("2026-09-04T12:00:00Z"), - .conditions = "The generated prompts in the approval test suite", - }); - try suite.addResult(.{ - .measure_id = "M-POLICY-CORRECTNESS", - .value = 1.0, - .sample_size = 24, - .method = .automated_test, - .measured_at = try zag.core.time.Timestamp.parseIso("2026-09-04T12:00:00Z"), - .conditions = "The policy test suite, including path traversal and expiry", - }); - - return .{ - .name = "zag workbench", - .version = zag.version, - .owner = "workbench maintainers", - .contact = "the repository issue tracker", - .license = "MIT", - .purpose = "Help a developer run commands, read and change code, and direct agents, on a machine they control.", - .intended_users = &.{"Software developers working on their own or their organisation's code."}, - .intended_uses = &.{ - "Running commands and reading their output as durable records.", - "Directing an agent to change code under a policy the person sets.", - "Reviewing what an agent did, and why.", - }, - .excluded_uses = &.{ - "Deciding anything about a person, such as employment, credit, benefits or access.", - "Running without a person able to see and stop what an agent does.", - }, - .model = .{ .provider = "configured by the operator", .model = "configured by the operator", .version = null }, - .model_inputs = &.{ - "The request the person typed.", - "Files the policy allows the agent to read.", - "Output of commands the agent ran.", - }, - .model_outputs = &.{ - "A plan, in words.", - "Typed tool requests, which the policy engine decides before anything runs.", - }, - .policy = try zag.ai.policy.repositoryWriteNoNetwork(".", arena), - .allowed_capabilities = &.{ .@"fs.read", .@"fs.write", .@"process.execute", .@"git.read", .@"git.commit" }, - .data_sources = &.{ - .{ - .name = "the workspace", - .description = "Files, commands and output in the repository the person opened.", - .personal_data = false, - .leaves_machine = false, - .retention = "Until the person deletes the workspace.", - }, - .{ - .name = "the event log", - .description = "Every typed event, chained by hash.", - .personal_data = false, - .leaves_machine = false, - .retention = "For as long as the retention rule on each record says.", - }, - }, - .limitations = &.{ - "It cannot tell whether a change an agent wrote is correct. It can only show you what changed and let you check.", - "Block boundaries depend on shell integration. Without it, timings and exit statuses are inferred and marked as inferred.", - "The graphical renderer and the language-server client are not built yet.", - }, - .human_oversight = "A person can stop any agent, refuse any approval, and withdraw any standing permission. Operations that cannot be undone always stop and ask.", - .evaluations = suite, - .risks = try zag.ai.risk.workbenchRegister(arena), - .impact = try zag.ai.impact.workbenchAssessment(arena), - .produced_at = try zag.core.time.Timestamp.parseIso("2026-09-04T00:00:00Z"), - .review_due = try zag.core.time.Timestamp.parseIso("2027-03-04T00:00:00Z"), - }; -} - -fn historyDataset() zag.ai.documentation.DatasetFacts { - return .{ - .name = "workspace command history", - .version = "2026-09", - .purpose = "Let a person search what they ran, where, and what happened.", - .made_by = "the workbench, from the person's own sessions", - .contents = "One record for each command: the text, the directory, the repository, the exit status and the time it took.", - .instance_count = 0, - .collection_method = "Recorded by the workbench as the person worked, from shell integration marks.", - .preparation = "Output larger than a few kilobytes is stored by hash rather than inline.", - .uses = &.{ "Searching earlier work.", "Explaining how a regression was introduced." }, - .unsupported_uses = &.{ "Judging how productive a person is.", "Training a model without the person's agreement." }, - .personal_data = true, - .consent_basis = "The person's own use of their own machine. Nothing is shared unless they share it.", - .retention = "Kept locally until the person deletes it. Nothing is sent anywhere by default.", - .limitations = &.{ - "Without shell integration, command boundaries are inferred and some records are incomplete.", - "Commands typed inside a full-screen program are not recorded separately.", - }, - .contact = "the repository issue tracker", - .produced_at = zag.core.time.Timestamp.epoch, - }; -} - -fn riskReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { - const register = try zag.ai.risk.workbenchRegister(arena); - try register.writeReport(w); - const findings = try register.review(); - return if (findings.items.len > 0) 1 else 0; -} - -fn impactReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { - const assessment = try zag.ai.impact.workbenchAssessment(arena); - try assessment.writeText(w); - const findings = try assessment.review(arena); - if (findings.items.len > 0) { - try w.writeAll("\nOpen items:\n"); - for (findings.items) |finding| try w.print(" - {s}\n", .{finding}); - return 1; - } - return 0; -} - -fn qualityReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { - const suite = try zag.ai.evaluation.workbenchSuite(arena); - try suite.writeReport(w); - return 0; -} - -fn accessibilityReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { - try zag.accessibility.wcag.writeStatement(w, .aa); - try w.writeAll("\n"); - try zag.accessibility.contrast.default_dark.writeReport(w, .aa); - try w.writeAll("\n"); - try zag.accessibility.contrast.default_light.writeReport(w, .aa); - - var status: u8 = 0; - inline for ([_]zag.accessibility.contrast.Theme{ - zag.accessibility.contrast.default_dark, - zag.accessibility.contrast.default_light, - }) |theme| { - const failures = try theme.check(arena, .aa); - if (failures.items.len > 0) status = 1; - } - - const tree_findings = try zag.accessibility.semantic_tree.check(arena, zag.accessibility.semantic_tree.exampleTree(), .{}); - try w.print("\nThe published accessibility tree has {d} problem(s).\n", .{tree_findings.items.len}); - for (tree_findings.items) |finding| try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); - if (tree_findings.items.len > 0) status = 1; - return status; -} - -fn exampleReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { - _ = arena; - const metrics = zag.reports.metrics; - const period: metrics.Period = .{ - .start = try zag.core.time.Timestamp.parseIso("2026-09-01T00:00:00Z"), - .end = try zag.core.time.Timestamp.parseIso("2026-09-08T00:00:00Z"), - }; - - const latency: metrics.Metric = .{ - .id = "latency", - .name = "Keystroke latency, 99th percentile", - .definition = "Time from a key press to the frame that shows it, on an idle machine.", - .kind = .quantile, - .unit = "ms", - .direction = .lower_is_better, - .source = "the render benchmark", - .decimals = 1, - }; - const commands: metrics.Metric = .{ - .id = "commands", - .name = "Commands run", - .definition = "Commands submitted through a session, including those an agent submitted.", - .kind = .flow, - .unit = "{command}", - .direction = .neutral, - .source = "the workspace event log", - }; - const denied: metrics.Metric = .{ - .id = "denied", - .name = "Tool calls denied by policy", - .definition = "Typed tool requests the policy refused, as a share of all tool requests.", - .kind = .rate, - .unit = "1", - .direction = .neutral, - .source = "the workspace event log", - .decimals = 2, - }; - - const table: zag.reports.notation.Table = .{ - .title = "Workspace, week of 1 September 2026", - .period = period, - .rows = &.{ - .{ .metric = latency, .value = .{ .metric_id = "latency", .value = 8.4, .period = period, .comparison = .{ .target = 10.0 }, .confidence = .sampled, .sample_size = 1000 } }, - .{ .metric = commands, .value = .{ .metric_id = "commands", .value = 412, .period = period, .comparison = .{ .previous_period = 388 } } }, - .{ .metric = denied, .value = .{ .metric_id = "denied", .value = 0.03, .period = period, .comparison = .{ .previous_period = 0.02 } } }, - }, - }; - try table.write(w); - - try w.writeAll("\n"); - const chart: zag.reports.notation.BarChart = .{ - .title = "Keystroke latency by build", - .metric = latency, - .bars = &.{ - .{ .label = "debug", .value = 18.2 }, - .{ .label = "release", .value = 8.4 }, - }, - }; - try chart.write(w); - return 0; -} - fn events(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { if (options.positional.len == 0) { try w.writeAll("Name the log file to read. For example: zag events workspace.jsonl\n"); @@ -1885,940 +1439,6 @@ fn workflowReport(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) return 0; } -fn lifecycleReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { - const record = zag.ai.lifecycle.workbenchRecord(); - try record.writeReport(arena, w); - const findings = try record.review(arena); - return if (findings.items.len > 0) 1 else 0; -} - -/// `zag evidence` — run the checks that can be run, record what each one -/// established, and write the conformance statement that follows from it. -/// Hash the bytes of the artefact a piece of evidence is about. -/// -/// Evidence used to carry `Hash.of("vocabulary")` — the hash of the word, not -/// of the thing. That is worse than no hash: it looks like a fingerprint, it is -/// stable across every change to the file, and it would let a record about one -/// version of an artefact be presented as a record about another. -/// -/// A file that cannot be read produces the zero hash, which is the honest -/// answer: the artefact was not fingerprinted, and a reader can tell. -fn artefactHash(arena: std.mem.Allocator, io: std.Io, path: []const u8) !zag.core.hash.Hash { - const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 << 20)) catch { - return zag.core.hash.Hash.zero; - }; - return zag.core.hash.Hash.of(bytes); -} - -/// Hash the themes that were actually checked. -/// -/// The colours are compiled in, so there is no file to read; hashing their -/// values is what makes the record specific to the palette that passed. -fn themeHash(arena: std.mem.Allocator) !zag.core.hash.Hash { - var out: std.Io.Writer.Allocating = .init(arena); - inline for ([_]zag.accessibility.contrast.Theme{ - zag.accessibility.contrast.default_dark, - zag.accessibility.contrast.default_light, - }) |theme| { - try out.writer.writeAll(theme.name); - for (theme.pairs) |pair| { - try out.writer.print("|{s}:{x:0>2}{x:0>2}{x:0>2}:{x:0>2}{x:0>2}{x:0>2}", .{ - pair.name, - pair.foreground.r, - pair.foreground.g, - pair.foreground.b, - pair.background.r, - pair.background.g, - pair.background.b, - }); - } - } - return zag.core.hash.Hash.of(out.written()); -} - -fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { - const profile = zag.standards.profile.byId(options.profile) orelse { - try w.print("\"{s}\" is not a profile zag knows. Run \"zag standards profiles\" to see them.\n", .{options.profile}); - return 2; - }; - - var registry = try loadStandards(arena); - _ = try registry.loadRequirements(@embedFile("registry_requirements")); - - var ledger = zag.standards.evidence.Ledger.init(arena); - var ids: zag.core.id.Generator = .init(0x2026_0904, 1_788_000_000_000); - const tool: zag.standards.evidence.ToolIdentity = .{ .name = "zag check", .version = zag.version }; - // The time the evidence was produced, from the clock. It used to be the - // epoch, so every record claimed to have been made on the first of January - // 1970 and nothing could be ordered or expired. - const now = wallClock(io); - - // Terminology. - const system = try zag.knowledge.vocabulary.build(arena); - const term_findings = try system.validate(); - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = "ISO-704:R-DEFINITION", - .subject = "src/knowledge/vocabulary.zig", - .method = .static_analysis, - .result = if (term_findings.items.len == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = try artefactHash(arena, io, "src/knowledge/vocabulary.zig"), - .note = "Every concept was checked for a definition that is present, not circular and not negative.", - }); - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = "ISO-704:R-ONE-TERM", - .subject = "src/knowledge/vocabulary.zig", - .method = .static_analysis, - .result = if (term_findings.items.len == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = try artefactHash(arena, io, "src/knowledge/vocabulary.zig"), - .note = "No preferred term designates two concepts.", - }); - - const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); - const reciprocity = try thesaurus.checkReciprocity(); - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = "ISO-25964:R-RECIPROCAL", - .subject = "the thesaurus view of the vocabulary", - .method = .static_analysis, - .result = if (reciprocity.items.len == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.of("thesaurus"), - }); - - // Metadata. - const metadata_registry = try workbenchMetadataRegistry(arena); - const metadata_findings = try metadata_registry.validate(); - inline for (.{ "ISO-11179:R-ONE-NAME", "ISO-11179:R-VALUE-DOMAIN" }) |requirement_id| { - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = requirement_id, - .subject = "the metadata registry", - .method = .static_analysis, - .result = if (metadata_findings.items.len == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.of("metadata"), - }); - } - - // Accessibility. - var contrast_failures: usize = 0; - inline for ([_]zag.accessibility.contrast.Theme{ - zag.accessibility.contrast.default_dark, - zag.accessibility.contrast.default_light, - }) |theme| { - contrast_failures += (try theme.check(arena, .aa)).items.len; - } - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = "WCAG-2.2:R-CONTRAST", - .subject = "the shipped themes", - .method = .automated_test, - .result = if (contrast_failures == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = try themeHash(arena), - }); - - // Target size and name-role-value are properties of a user interface. This - // build has no renderer, so there is no interface to measure them on. The - // checker runs against an example tree, and a pass against a fixture is - // evidence about the fixture: recording it as a pass for the product would - // be the exact thing the conformance rules forbid. It is recorded as not - // tested, with the reason, until there is something to test. - inline for (.{ "WCAG-2.2:R-TARGET-SIZE", "WCAG-2.2:R-NAME-ROLE-VALUE" }) |requirement_id| { - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = requirement_id, - .subject = "no user interface exists yet", - .method = .automated_test, - .result = .not_tested, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.zero, - .note = "This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see.", - }); - } - - // Governance. - const risk_register = try zag.ai.risk.workbenchRegister(arena); - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = "ISO-23894:R-TREATED", - .subject = "the risk register", - .method = .static_analysis, - .result = if ((try risk_register.review()).items.len == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = try artefactHash(arena, io, "src/ai/risk.zig"), - }); - const assessment = try zag.ai.impact.workbenchAssessment(arena); - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = "ISO-42005:R-AFFECTED", - .subject = "the impact assessment", - .method = .static_analysis, - .result = if ((try assessment.review(arena)).items.len == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.of("impact"), - }); - - // Product text. - const help_report = try zag.language.plain.check(arena, help_text, .{ .kind = .body, .audience = profile.audience, .terminology = &system }); - var blocking: usize = 0; - for (help_report.findings) |finding| { - if (finding.severity == .blocking) blocking += 1; - } - // Each requirement answers for its own rule, not for "nothing blocking". - // - // Only two rules in the whole checker can produce a blocking finding, so - // "no blocking problem" was a much weaker statement than the `pass` beside - // it implied: a document could break sentence length, leave abbreviations - // undefined and label a button "OK", and still be recorded as passing four - // requirements. A machine reading the record sees the result, not the note. - // - // Two of these have a rule that checks them, so they are answered by that - // rule. The other two are about whether a reader can find and understand - // the text, which no checker establishes, so they are recorded as partly - // established and say what is missing. - const NarrowRule = struct { - requirement: []const u8, - rule: zag.language.plain.Rule, - }; - inline for ([_]NarrowRule{ - .{ .requirement = "ISO-24495-1:R-ERROR-NEXT-STEP", .rule = .error_without_next_step }, - .{ .requirement = "ISO-24495-1:R-LABEL-ACTION", .rule = .label_without_action }, - }) |checked| { - var breaches: usize = 0; - for (help_report.findings) |finding| { - if (finding.rule == checked.rule) breaches += 1; - } - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = checked.requirement, - .subject = "the product's own text and the tool's help", - .method = .static_analysis, - .result = if (breaches == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.of(help_text), - .note = "A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems.", - }); - } - inline for (.{ "ISO-24495-1:R-UNDERSTANDABLE", "ISO-24495-1:R-FINDABLE" }) |requirement_id| { - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = requirement_id, - .subject = "the product's own text and the tool's help", - .method = .static_analysis, - .result = if (blocking == 0) .partial else .fail, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.of(help_text), - .note = "A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done.", - }); - } - - // The workspace knowledge base, against ISO 30401 and ISO 10013. - const workspace = zag.workspace.service.Service.open(arena, io, .{ - .root = options.repo, - .actor = workbenchActor(), - .now = wallClock(io), - }) catch |err| { - try w.print("zag could not read workspace knowledge in {s}: {s}. Fix the path or permissions, then generate the evidence again. No evidence file was written.\n", .{ - options.repo, - @errorName(err), - }); - return 1; - }; - { - var missing_owner: usize = 0; - var overdue: usize = 0; - var stateless: usize = 0; - for (workspace.report.knowledgeFindings) |finding| { - switch (finding.code) { - .missing_owner => missing_owner += 1, - .review_overdue => overdue += 1, - .approved_without_review_date => stateless += 1, - else => {}, - } - } - const knowledge_checks = [_]struct { - requirement_id: []const u8, - failures: usize, - note: []const u8, - }{ - .{ - .requirement_id = "ISO-30401:R-OWNER", - .failures = missing_owner, - .note = "Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here.", - }, - .{ - .requirement_id = "ISO-30401:R-CURRENT", - .failures = overdue, - .note = "No approved entry is past its review date. Whether the content is still correct is established by review.", - }, - .{ - .requirement_id = "ISO-10013:R-STATE", - .failures = stateless, - .note = "Every entry carries a state, so a draft cannot be read as approved.", - }, - }; - for (knowledge_checks) |entry| { - try ledger.record(.{ - .id = ids.next(zag.core.id.EvidenceId), - .requirement_id = entry.requirement_id, - .subject = "the knowledge base under .workspace/", - .method = .static_analysis, - .result = if (entry.failures == 0) .pass else .fail, - .tool = tool, - .created_at = now, - .content_hash = zag.core.hash.Hash.of("workspace-knowledge"), - .note = entry.note, - }); - } - } - - // The statement, for each scope the profile covers. - const scopes = [_]zag.standards.registry.Scope{ .interface_text, .documentation, .terminology, .metadata, .user_interface, .ai_system, .records, .reporting, .data, .process }; - var worst: u8 = 0; - for (scopes) |scope| { - const summary = try ledger.summarise(registry, scope, profile.id); - if (summary.total == 0) continue; - try ledger.writeStatement(w, registry, scope, profile.id); - try w.writeAll("\n"); - if (summary.claim == .not_conformant) worst = 1; - } - - // The evidence itself is a record, so it is written rather than printed and - // forgotten. It is deterministic: the same repository produces the same - // file, and a change in the file means a change in what was established. - var jsonl: std.Io.Writer.Allocating = .init(arena); - for (ledger.items.items) |item| { - try std.json.Stringify.value(item, .{}, &jsonl.writer); - try jsonl.writer.writeByte('\n'); - } - try writeGenerated(io, std.Io.Dir.cwd(), arena, options.out, "evidence/conformance.jsonl", jsonl.written()); - try w.print("Evidence written to {s}/evidence/conformance.jsonl\n", .{options.out}); - return worst; -} - -// --------------------------------------------------------------------------- -// emit and check -// --------------------------------------------------------------------------- - -fn emit(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { - const dir = std.Io.Dir.cwd(); - var written: usize = 0; - - const schemas = [_]struct { path: []const u8, body: []const u8 }{ - .{ - .path = "schemas/workspace-event.schema.json", - .body = try zag.interop.jsonschema.documentAlloc(zag.events.event.Envelope, arena, .{ - .id = "https://zag.dev/schemas/workspace-event.schema.json", - .title = "Workspace event", - .description = "One entry in the workspace event log.", - }), - }, - .{ - .path = "schemas/tool-request.schema.json", - .body = try zag.interop.jsonschema.documentAlloc(zag.ai.tools.ToolRequest, arena, .{ - .id = "https://zag.dev/schemas/tool-request.schema.json", - .title = "Tool request", - .description = "Everything an agent can ask to do. Each request names one capability.", - }), - }, - .{ - .path = "schemas/block.schema.json", - .body = try zag.interop.jsonschema.documentAlloc(zag.workspace.block.Block, arena, .{ - .id = "https://zag.dev/schemas/block.schema.json", - .title = "Block", - .description = "One unit of work, with what caused it and what it produced.", - }), - }, - }; - for (schemas) |schema| { - try writeGenerated(io, dir, arena, options.out, schema.path, schema.body); - try w.print("wrote {s}\n", .{schema.path}); - written += 1; - } - - // The daemon's interface, generated from the same types. - const openapi_body = try zag.interop.openapi.documentAlloc(&daemon_operations, arena, .{ - .title = "zag workspace daemon", - .version = zag.version, - .summary = "Read and drive a workspace over HTTP.", - .server_url = "http://127.0.0.1:7717", - }); - try writeGenerated(io, dir, arena, options.out, "schemas/openapi.json", openapi_body); - try w.writeAll("wrote schemas/openapi.json\n"); - written += 1; - - // An example settings file, with every value at its default. Somebody - // starting one from nothing should not have to read Zig to find out what - // the settings are called. - var example_settings: std.Io.Writer.Allocating = .init(arena); - try zag.terminal.settings.writeExample(&example_settings.writer); - try writeGenerated(io, dir, arena, options.out, "docs/settings.example.toml", example_settings.written()); - try w.writeAll("wrote docs/settings.example.toml\n"); - written += 1; - - // The metadata registry, so other languages can adopt the same names. - var registry_text: std.Io.Writer.Allocating = .init(arena); - const registry = try workbenchMetadataRegistry(arena); - try registry.writeJson(®istry_text.writer); - try writeGenerated(io, dir, arena, options.out, "schemas/metadata-registry.json", registry_text.written()); - try w.writeAll("wrote schemas/metadata-registry.json\n"); - written += 1; - - // The vocabulary, in the two forms other systems ask for. - const system = try zag.knowledge.vocabulary.build(arena); - var graph = try zag.knowledge.skos.buildGraph(arena, system, zag.knowledge.skos.default_scheme); - - var turtle: std.Io.Writer.Allocating = .init(arena); - try graph.writeTurtle(&turtle.writer); - try writeGenerated(io, dir, arena, options.out, "vocab/workspace.ttl", turtle.written()); - try w.writeAll("wrote vocab/workspace.ttl\n"); - - var jsonld: std.Io.Writer.Allocating = .init(arena); - try graph.writeJsonLd(&jsonld.writer); - try writeGenerated(io, dir, arena, options.out, "vocab/workspace.jsonld", jsonld.written()); - try w.writeAll("wrote vocab/workspace.jsonld\n"); - - var alphabetical: std.Io.Writer.Allocating = .init(arena); - const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); - try thesaurus.writeAlphabetical(&alphabetical.writer); - try writeGenerated(io, dir, arena, options.out, "vocab/workspace.thesaurus.txt", alphabetical.written()); - try w.writeAll("wrote vocab/workspace.thesaurus.txt\n"); - - // The glossary and the risk register are generated, not maintained by - // hand, so they cannot fall behind the code they describe. - var glossary: std.Io.Writer.Allocating = .init(arena); - try glossary.writer.writeAll( - \\# Glossary - \\ - \\Generated by `zag emit` from the concept system in `src/knowledge`. Do not - \\edit this file: change the concept, and the definition changes everywhere it - \\is used, including in the product's own text checks. - \\ - \\ - ); - var concept_it = system.concepts.iterator(); - while (concept_it.next()) |entry| { - const concept = entry.value_ptr.*; - try glossary.writer.print("## {s}\n\n{s}\n\n", .{ concept.preferred, concept.definition }); - for (concept.designations) |designation| { - switch (designation.status) { - .admitted => try glossary.writer.print("Also called: {s}\n\n", .{designation.text}), - .deprecated => try glossary.writer.print("Do not use: {s}\n\n", .{designation.text}), - .preferred => {}, - } - } - if (concept.source) |source| try glossary.writer.print("From: {s}\n\n", .{source}); - } - try writeGenerated(io, dir, arena, options.out, "docs/glossary.md", glossary.written()); - try w.writeAll("wrote docs/glossary.md\n"); - - var risks: std.Io.Writer.Allocating = .init(arena); - try risks.writer.writeAll( - \\# Risk register - \\ - \\Generated by `zag emit` from `src/ai/risk.zig`. A treatment that claims to - \\reduce a risk must name a control that is enforced by code; an instruction to - \\a model is not a control, and the build says so. - \\ - \\ - ); - const register = try zag.ai.risk.workbenchRegister(arena); - try register.writeReport(&risks.writer); - try writeGenerated(io, dir, arena, options.out, "docs/risk-register.md", risks.written()); - try w.writeAll("wrote docs/risk-register.md\n"); - - // The machine-readable index for agents. - var llms: std.Io.Writer.Allocating = .init(arena); - try zag.agent_context.llms_txt.write(zag.agent_context.llms_txt.workbenchDocument(), &llms.writer); - try writeGenerated(io, dir, arena, options.out, "llms.txt", llms.written()); - try w.writeAll("wrote llms.txt\n"); - - return 0; -} - -fn writeGenerated( - io: std.Io, - dir: std.Io.Dir, - arena: std.mem.Allocator, - out_root: []const u8, - relative_path: []const u8, - body: []const u8, -) !void { - const full = try std.fs.path.join(arena, &.{ out_root, relative_path }); - if (std.fs.path.dirname(full)) |parent| { - try dir.createDirPath(io, parent); - } - try dir.writeFile(io, .{ .sub_path = full, .data = body }); -} - -const daemon_operations = [_]zag.interop.openapi.Operation{ - .{ - .method = .get, - .path = "/health", - .operation_id = "getHealth", - .summary = "Report whether the daemon is running", - .response = Health, - .tags = &.{"system"}, - }, - .{ - .method = .get, - .path = "/sessions", - .operation_id = "listSessions", - .summary = "List the sessions in this workspace", - .response = SessionList, - .capability = "fs.read", - .tags = &.{"sessions"}, - }, - .{ - .method = .get, - .path = "/sessions/{sessionId}/blocks", - .operation_id = "listBlocks", - .summary = "List the blocks in one session", - .response = BlockList, - .capability = "fs.read", - .tags = &.{"sessions"}, - }, - .{ - .method = .post, - .path = "/sessions/{sessionId}/commands", - .operation_id = "submitCommand", - .summary = "Run a command in one session", - .request = SubmitCommand, - .response = zag.workspace.block.Block, - .capability = "process.execute", - .tags = &.{"sessions"}, - }, - .{ - .method = .get, - .path = "/events", - .operation_id = "listEvents", - .summary = "Read the workspace event log from a sequence number", - .response = EventPage, - .capability = "fs.read", - .tags = &.{"events"}, - }, - .{ - .method = .post, - .path = "/approvals/{approvalId}", - .operation_id = "answerApproval", - .summary = "Answer a request that is waiting for a person", - .request = ApprovalAnswer, - .response = ApprovalResult, - .capability = "process.execute", - .tags = &.{"approvals"}, - }, -}; - -const Health = struct { status: []const u8, version: []const u8, pseudoterminalSupported: bool }; -const SessionList = struct { sessions: []const zag.workspace.model.Session }; -const BlockList = struct { blocks: []const zag.workspace.block.Block }; -const SubmitCommand = struct { commandText: []const u8, workingDirectory: []const u8 }; -const EventPage = struct { events: []const zag.events.event.Envelope, nextSequence: u64 }; -const ApprovalAnswer = struct { outcome: []const u8, note: []const u8 }; -const ApprovalResult = struct { accepted: bool, message: []const u8 }; - -fn workbenchMetadataRegistry(arena: std.mem.Allocator) !zag.metadata.registry.Registry { - var registry = zag.metadata.registry.Registry.init(arena); - - try registry.addConcept(.{ .id = "process.working-directory", .object_class = "process", .property = "working directory", .definition = "Directory a process resolves relative paths against.", .concept_id = "session" }); - try registry.addConcept(.{ .id = "process.exit-status", .object_class = "process", .property = "exit status", .definition = "Status a process returned when it finished.", .concept_id = "block" }); - try registry.addConcept(.{ .id = "event.instant", .object_class = "workspace event", .property = "instant", .definition = "The moment the event happened.", .concept_id = "workspace-event" }); - try registry.addConcept(.{ .id = "artifact.content-hash", .object_class = "artefact", .property = "content hash", .definition = "Hash of the content this record refers to.", .concept_id = "artifact" }); - try registry.addConcept(.{ .id = "capability.name", .object_class = "capability", .property = "name", .definition = "The capability an operation needs.", .concept_id = "capability" }); - - try registry.addDomain(.{ .id = "path", .definition = "Filesystem path.", .datatype = .path, .maximum_length = 4096 }); - try registry.addDomain(.{ .id = "exit-status", .definition = "Process exit status.", .datatype = .integer, .minimum = 0, .maximum = 255 }); - try registry.addDomain(.{ .id = "instant", .definition = "Point in time, in UTC.", .datatype = .timestamp }); - try registry.addDomain(.{ .id = "content-hash", .definition = "BLAKE3-256 hash in the workbench's canonical form.", .datatype = .content_hash }); - try registry.addDomain(.{ - .id = "capability-name", - .definition = "One of the capabilities the policy engine knows.", - .datatype = .code, - .permitted = &.{ - .{ .value = "fs.read", .meaning = "Read a file or list a directory" }, - .{ .value = "fs.write", .meaning = "Create or change a file" }, - .{ .value = "fs.delete", .meaning = "Delete a file or directory" }, - .{ .value = "process.execute", .meaning = "Start a process" }, - .{ .value = "network.connect", .meaning = "Open a network connection" }, - .{ .value = "git.commit", .meaning = "Record a commit" }, - .{ .value = "git.push", .meaning = "Send commits to a remote" }, - .{ .value = "model.infer", .meaning = "Send a request to a model provider" }, - }, - }); - - try registry.addElement(.{ - .id = "workingDirectory", - .concept_id = "process.working-directory", - .domain_id = "path", - .canonical_key = "workingDirectory", - .zig_type = "[]const u8", - .aliases = &.{ "cwd", "working_directory", "current_path", "directory" }, - .obligation = .conditional, - .status = .standardised, - .definition = "Directory the process resolves relative paths against.", - .example = "/home/user/zag", - }); - try registry.addElement(.{ - .id = "exitStatus", - .concept_id = "process.exit-status", - .domain_id = "exit-status", - .canonical_key = "exitStatus", - .zig_type = "?u8", - .aliases = &.{ "exit_code", "exitCode", "returncode", "status_code" }, - .obligation = .conditional, - .status = .standardised, - .definition = "Status the process returned when it finished.", - .example = "0", - }); - try registry.addElement(.{ - .id = "at", - .concept_id = "event.instant", - .domain_id = "instant", - .canonical_key = "at", - .zig_type = "Timestamp", - .aliases = &.{ "timestamp", "time", "ts", "occurred_at" }, - .obligation = .mandatory, - .status = .standardised, - .definition = "The moment the event happened, in UTC.", - .example = "2026-09-04T20:41:31.500Z", - }); - try registry.addElement(.{ - .id = "contentHash", - .concept_id = "artifact.content-hash", - .domain_id = "content-hash", - .canonical_key = "contentHash", - .zig_type = "Hash", - .aliases = &.{ "content_hash", "digest", "sha", "checksum" }, - .obligation = .mandatory, - .status = .standardised, - .definition = "Hash of the content this record refers to.", - .example = "b3:0000000000000000000000000000000000000000000000000000000000000000", - }); - try registry.addElement(.{ - .id = "capability", - .concept_id = "capability.name", - .domain_id = "capability-name", - .canonical_key = "capability", - .zig_type = "Capability", - .aliases = &.{ "permission", "scope", "grant" }, - .obligation = .mandatory, - .status = .standardised, - .definition = "The capability an operation needs.", - .example = "process.execute", - }); - - return registry; -} - -/// `zag check` — the release gate. -fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { - const profile = zag.standards.profile.byId(options.profile) orelse { - try w.print("\"{s}\" is not a profile zag knows. Run \"zag standards profiles\" to see them.\n", .{options.profile}); - return 2; - }; - - try w.print("Checking {s} against the \"{s}\" profile.\n\n", .{ options.repo, profile.name }); - - var problems: usize = 0; - const dir = std.Io.Dir.cwd(); - - // 1. The standards registry itself, and the requirements taken from it. - // - // The requirements were not loaded here at all, so anything checking a - // requirement identifier was checking against an empty set: every lookup - // failed, and nothing looked one up, so nothing noticed. - var registry = try loadStandards(arena); - const requirement_count = try registry.loadRequirements(@embedFile("registry_requirements")); - const issues = try registry.validate(); - try w.print("Standards registry: {d} standards and {d} requirements recorded, {d} problem(s).\n", .{ - registry.count(), - requirement_count, - issues.items.len, - }); - for (issues.items) |issue| { - try w.print(" {s} {s}\n", .{ issue.code.text(), issue.message }); - problems += 1; - } - - // 1a. Every requirement a profile names has to exist. - // - // `extra_requirements` and `exclusions` are lists of identifiers, and until - // now nothing read either one. A profile could switch on a requirement that - // did not exist, or exclude one with a typo in it, and the build would - // report nothing: the exclusion silently applied to nothing, so a - // requirement the author meant to skip was enforced, or one they meant to - // add was not. - for (zag.standards.profile.registry) |candidate| { - for (candidate.extra_requirements) |id| { - if (registry.requirements.get(id) == null) { - try w.print(" PROFILE {s} switches on {s}, which is not a requirement.\n", .{ candidate.id, id }); - problems += 1; - } - } - for (candidate.exclusions) |exclusion| { - const known = switch (exclusion.subject) { - .requirement => |id| registry.requirements.get(id) != null, - .language_rule => |code| zag.language.plain.Rule.byCode(code) != null, - }; - if (!known) { - try w.print(" PROFILE {s} excludes {s}, which is not a {s}.\n", .{ - candidate.id, - exclusion.subject.text(), - @tagName(exclusion.subject), - }); - problems += 1; - } - } - } - - // 1b. Any file a standard points at has to be there. One entry named a - // requirements file that had never existed; the field was parsed, never - // read, and never checked, so nothing noticed. - for (registry.standards.values()) |standard| { - const named = standard.requirements_file orelse continue; - const full = try std.fs.path.join(arena, &.{ options.repo, named }); - dir.access(io, full, .{}) catch { - try w.print(" REGISTRY {s} names {s}, which is not there.\n", .{ standard.identifier, named }); - problems += 1; - }; - } - - // 2. The vocabulary. - const system = try zag.knowledge.vocabulary.build(arena); - const term_findings = try system.validate(); - try w.print("Vocabulary: {d} concepts, {d} problem(s).\n", .{ system.count(), term_findings.items.len }); - for (term_findings.items) |finding| { - try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); - problems += 1; - } - - const thesaurus = try zag.knowledge.thesaurus.Thesaurus.build(arena, system); - const reciprocity = try thesaurus.checkReciprocity(); - for (reciprocity.items) |problem| { - try w.print(" TERM {s}\n", .{problem}); - problems += 1; - } - - // 3. The metadata registry. - const metadata_registry = try workbenchMetadataRegistry(arena); - const metadata_findings = try metadata_registry.validate(); - try w.print("Metadata registry: {d} elements, {d} problem(s).\n", .{ metadata_registry.elements.count(), metadata_findings.items.len }); - for (metadata_findings.items) |finding| { - try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); - problems += 1; - } - - // 4. Accessibility. - var contrast_problems: usize = 0; - inline for ([_]zag.accessibility.contrast.Theme{ - zag.accessibility.contrast.default_dark, - zag.accessibility.contrast.default_light, - }) |theme| { - const failures = try theme.check(arena, .aa); - for (failures.items) |failure| { - try w.print(" CONTRAST {s} in {s}: {d:.2}:1, needs {d:.1}:1\n", .{ failure.pair.name, theme.name, failure.result.ratio, failure.result.required }); - contrast_problems += 1; - } - } - const tree_findings = try zag.accessibility.semantic_tree.check(arena, zag.accessibility.semantic_tree.exampleTree(), .{}); - try w.print("Accessibility: {d} contrast problem(s), {d} tree problem(s).\n", .{ contrast_problems, tree_findings.items.len }); - for (tree_findings.items) |finding| try w.print(" {s} {s}\n", .{ finding.code.text(), finding.message }); - problems += contrast_problems + tree_findings.items.len; - - // 5. Risk, impact and transparency. - const risk_register = try zag.ai.risk.workbenchRegister(arena); - const risk_findings = try risk_register.review(); - const assessment = try zag.ai.impact.workbenchAssessment(arena); - const impact_findings = try assessment.review(arena); - try w.print("Governance: {d} risk problem(s), {d} impact problem(s).\n", .{ risk_findings.items.len, impact_findings.items.len }); - for (risk_findings.items) |finding| try w.print(" RISK {s}\n", .{finding.message}); - for (impact_findings.items) |finding| try w.print(" IMPACT {s}\n", .{finding}); - problems += risk_findings.items.len + impact_findings.items.len; - - // An obligation nobody has met is a problem, and printing it as OPEN while - // leaving the count at zero meant the build stayed green with a list of - // things it had just said were not done. A check that reports and does not - // fail is a check that stops being read. - const unmet = try zag.ai.transparency.unmetObligations(arena); - try w.print("Transparency: {d} obligation(s) not yet met.\n", .{unmet.items.len}); - for (unmet.items) |obligation| { - try w.print(" OPEN {s} {s}\n", .{ obligation.topic.question(), obligation.gap }); - problems += 1; - } - - // 6. The product's own text, including this tool's help. - var text_findings: usize = 0; - const documents = [_]struct { path: []const u8, kind: zag.language.audience.TextKind }{ - .{ .path = "README.md", .kind = .body }, - .{ .path = "AGENTS.md", .kind = .body }, - .{ .path = "docs/architecture.md", .kind = .body }, - .{ .path = "docs/standards-conformance.md", .kind = .body }, - .{ .path = "docs/plain-language.md", .kind = .body }, - .{ .path = "docs/competitive-position.md", .kind = .body }, - .{ .path = "docs/roadmap.md", .kind = .body }, - .{ .path = "docs/threat-model.md", .kind = .body }, - .{ .path = "docs/platform-support.md", .kind = .body }, - .{ .path = "docs/if-something-is-wrong.md", .kind = .body }, - .{ .path = "CONTRIBUTING.md", .kind = .body }, - .{ .path = "SECURITY.md", .kind = .body }, - }; - for (documents) |document| { - const path = try std.fs.path.join(arena, &.{ options.repo, document.path }); - const source = dir.readFileAlloc(io, path, arena, .limited(8 << 20)) catch |err| { - try w.print(" TEXT zag could not read {s}: {s}.\n", .{ document.path, @errorName(err) }); - text_findings += 1; - continue; - }; - const report = try zag.language.plain.check(arena, source, .{ - .kind = document.kind, - .audience = profile.audience, - .terminology = &system, - }); - for (report.findings) |finding| { - if (finding.severity != .blocking) continue; - try w.print(" {s} {s}:{d} {s}\n", .{ finding.rule.code(), document.path, finding.line, finding.message }); - text_findings += 1; - } - } - const help_report = try zag.language.plain.check(arena, help_text, .{ - .kind = .body, - .audience = zag.language.audience.new_user, - .terminology = &system, - }); - for (help_report.findings) |finding| { - if (finding.severity != .blocking) continue; - try w.print(" {s} zag help: {s}\n", .{ finding.rule.code(), finding.message }); - text_findings += 1; - } - try w.print("Product text: {d} blocking problem(s).\n", .{text_findings}); - problems += text_findings; - - // 7. The repository's own workflow and lifecycle record. - const workflow = try zag.workspace.workflow.buildAndCheck(arena, options.repo); - const workflow_findings = try workflow.validate(arena); - const lifecycle_findings = try zag.ai.lifecycle.workbenchRecord().review(arena); - try w.print("Workflow and lifecycle: {d} workflow problem(s), {d} lifecycle problem(s).\n", .{ workflow_findings.items.len, lifecycle_findings.items.len }); - for (workflow_findings.items) |finding| try w.print(" WORKFLOW {s}: {s}\n", .{ finding.node_id, finding.message }); - for (lifecycle_findings.items) |finding| try w.print(" LIFECYCLE {s}\n", .{finding}); - problems += workflow_findings.items.len + lifecycle_findings.items.len; - - // 7b. The workspace policy. A policy that does not parse allows nothing, - // which is safe but is never what the person who wrote it meant, so the - // build says so rather than letting it sit there doing nothing. - { - const policy_path = try std.fs.path.join(arena, &.{ options.repo, zag.ai.policy_file.default_path }); - if (dir.readFileAlloc(io, policy_path, arena, .limited(1 << 20))) |source| { - const result = try zag.ai.policy_file.parse(arena, source); - if (result.policy) |written| { - try w.print("Workspace policy: {d} rule(s), no problems.\n", .{written.rules.len}); - } else { - try w.print("Workspace policy: {d} problem(s). Nothing in the file is in force.\n", .{result.problems.len}); - for (result.problems) |problem| { - if (problem.rule) |number| { - try w.print(" POLICY rule {d}: {s}\n", .{ number, problem.message }); - } else { - try w.print(" POLICY {s}\n", .{problem.message}); - } - problems += 1; - } - } - } else |_| { - try w.writeAll("Workspace policy: none written, so the built-in one is in force.\n"); - } - } - - // 8. Instruction files. - const agents_path = try std.fs.path.join(arena, &.{ options.repo, "AGENTS.md" }); - if (dir.readFileAlloc(io, agents_path, arena, .limited(4 << 20))) |source| { - const document = try zag.agent_context.agents_md.parse(arena, options.repo, source); - try w.print("Instruction file: {d} instruction(s), {d} finding(s).\n", .{ document.instructions.len, document.findings.len }); - for (document.findings) |finding| { - try w.print(" {s} AGENTS.md:{d} {s}\n", .{ finding.code.text(), finding.line, finding.message }); - problems += 1; - } - } else |err| { - try w.print("Instruction file: zag could not read AGENTS.md: {s}.\n", .{@errorName(err)}); - problems += 1; - } - - // 9. The workspace knowledge base. ISO 30401 asks for named owners and for - // knowledge that is kept current; this is where the product answers for - // its own knowledge rather than only for its code. - const opened = zag.workspace.service.Service.open(arena, io, .{ - .root = options.repo, - .actor = workbenchActor(), - .now = wallClock(io), - }) catch |err| blk: { - try w.print("Workspace knowledge: zag could not open it: {s}.\n", .{@errorName(err)}); - problems += 1; - break :blk null; - }; - if (opened) |workspace| { - try w.print("Workspace knowledge: {d} entries, {d} problem(s).\n", .{ - workspace.report.knowledgeCount, - workspace.report.knowledgeFindings.len, - }); - for (workspace.report.knowledgeFindings) |finding| { - try w.print(" {s} {s}: {s}\n", .{ finding.code.text(), finding.path, finding.message }); - problems += 1; - } - if (!workspace.report.logIsHealthy()) { - try w.writeAll(" LOG The event log is incomplete or damaged.\n"); - problems += 1; - } - // Knowledge an agent reads is product text, so it is held to the same - // language rules as the documentation. - for (workspace.service.knowledge.entries.items) |entry| { - const entry_report = try zag.language.plain.check(arena, entry.body, .{ - .kind = .body, - .audience = profile.audience, - .terminology = &system, - }); - for (entry_report.findings) |finding| { - if (finding.severity != .blocking) continue; - try w.print(" {s} {s}:{d} {s}\n", .{ finding.rule.code(), entry.path, finding.line, finding.message }); - problems += 1; - } - } - } - - // The statement. - try w.writeAll("\n"); - if (problems == 0) { - try w.writeAll("Every check that a program can run has passed.\n"); - } else { - try w.print("{d} problem(s) found by checks a program can run.\n", .{problems}); - } - try w.writeAll( - \\ - \\What this does not establish: whether a reader can find, understand and use - \\the information; whether a person using a screen reader can finish a task; - \\whether the risk treatments work in practice. Those are settled by evaluating - \\the product with people, and by audit. This report says only what was checked. - \\ - ); - return if (problems == 0) 0 else 1; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - const testing = std.testing; test "the help text passes the product's own plain-language rules" { @@ -2871,8 +1491,8 @@ test "options parse, including the passthrough after two dashes" { defer arena_state.deinit(); const arena = arena_state.allocator(); - const options = try parseOptions(arena, &.{ "--profile", "legal", "--json", "--approve-truncate", "file.md", "--", "echo", "hello" }); - try testing.expectEqualStrings("legal", options.profile); + const options = try parseOptions(arena, &.{ "--repo", "elsewhere", "--json", "--approve-truncate", "file.md", "--", "echo", "hello" }); + try testing.expectEqualStrings("elsewhere", options.repo); try testing.expect(options.json); try testing.expect(options.approve_truncate); try testing.expectEqual(@as(usize, 1), options.positional.len); @@ -2880,28 +1500,12 @@ test "options parse, including the passthrough after two dashes" { try testing.expectEqual(@as(usize, 2), options.passthrough.len); try testing.expectEqualStrings("hello", options.passthrough[1]); - try testing.expectError(error.MissingOptionValue, parseOptions(arena, &.{"--profile"})); + try testing.expectError(error.MissingOptionValue, parseOptions(arena, &.{"--repo"})); try testing.expectError(error.UnknownOption, parseOptions(arena, &.{"--wat"})); try testing.expect(approvalOptionIsValid(.recover, options)); try testing.expect(!approvalOptionIsValid(.doctor, options)); } -test "the metadata registry the tool publishes is valid" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const registry = try workbenchMetadataRegistry(arena); - const findings = try registry.validate(); - if (findings.items.len > 0) { - for (findings.items) |finding| std.debug.print("{s} {s}\n", .{ finding.code.text(), finding.message }); - } - try testing.expectEqual(@as(usize, 0), findings.items.len); - try testing.expectEqualStrings("workingDirectory", registry.canonicalKey("cwd").?); - try testing.expectEqualStrings("exitStatus", registry.canonicalKey("exit_code").?); - try testing.expectEqualStrings("at", registry.canonicalKey("timestamp").?); -} - test "events rejects a malformed complete record without hiding it as torn" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -3001,46 +1605,6 @@ test "recover reports an absent event log as a clean no-op" { try testing.expectError(error.FileNotFound, std.Io.Dir.cwd().openFile(io, event_path, .{})); } -test "evidence generation never hides an unreadable workspace" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); - const event_path = try std.fmt.allocPrint(arena, "{s}/.workspace/events.jsonl", .{root}); - try std.Io.Dir.cwd().createDirPath(io, event_path); - - var buffer: [2048]u8 = undefined; - var writer = std.Io.Writer.fixed(&buffer); - const status = try evidenceReport(arena, io, &writer, .{ .repo = root }); - try testing.expectEqual(@as(u8, 1), status); - try testing.expect(std.mem.indexOf(u8, writer.buffered(), "No evidence file was written") != null); -} - -test "the daemon interface describes every operation with its capability" { - const gpa = testing.allocator; - const text = try zag.interop.openapi.documentAlloc(&daemon_operations, gpa, .{ - .title = "zag workspace daemon", - .version = "0.1.0", - }); - defer gpa.free(text); - - const parsed = try std.json.parseFromSlice(std.json.Value, gpa, text, .{}); - defer parsed.deinit(); - const paths = parsed.value.object.get("paths").?.object; - try testing.expect(paths.contains("/sessions")); - try testing.expect(paths.contains("/events")); - - const submit = paths.get("/sessions/{sessionId}/commands").?.object.get("post").?.object; - try testing.expectEqualStrings("process.execute", submit.get("x-zag-capability").?.string); - try testing.expect(submit.get("requestBody") != null); -} - test "the connector list says which credentials are set, without printing one" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/daemon/interface.zig b/src/daemon/interface.zig new file mode 100644 index 0000000..f16b0f0 --- /dev/null +++ b/src/daemon/interface.zig @@ -0,0 +1,79 @@ +//! The workspace daemon's HTTP interface, described once. +//! +//! This lived in the command-line tool, where `zag emit` reads it to generate +//! the OpenAPI document. It describes the daemon, not either tool, and now that +//! the generator lives in a second binary it has to be somewhere both can see. +//! Describing an interface in the tool that happens to publish it is how the +//! description and the server drift apart. + +const std = @import("std"); +const openapi = @import("../interop/openapi.zig"); +const model = @import("../workspace/model.zig"); +const block_mod = @import("../workspace/block.zig"); +const event = @import("../events/event.zig"); + +const Health = struct { status: []const u8, version: []const u8, pseudoterminalSupported: bool }; +const SessionList = struct { sessions: []const model.Session }; +const BlockList = struct { blocks: []const block_mod.Block }; +const SubmitCommand = struct { commandText: []const u8, workingDirectory: []const u8 }; +const EventPage = struct { events: []const event.Envelope, nextSequence: u64 }; +const ApprovalAnswer = struct { outcome: []const u8, note: []const u8 }; +const ApprovalResult = struct { accepted: bool, message: []const u8 }; + +pub const daemon_operations = [_]openapi.Operation{ + .{ + .method = .get, + .path = "/health", + .operation_id = "getHealth", + .summary = "Report whether the daemon is running", + .response = Health, + .tags = &.{"system"}, + }, + .{ + .method = .get, + .path = "/sessions", + .operation_id = "listSessions", + .summary = "List the sessions in this workspace", + .response = SessionList, + .capability = "fs.read", + .tags = &.{"sessions"}, + }, + .{ + .method = .get, + .path = "/sessions/{sessionId}/blocks", + .operation_id = "listBlocks", + .summary = "List the blocks in one session", + .response = BlockList, + .capability = "fs.read", + .tags = &.{"sessions"}, + }, + .{ + .method = .post, + .path = "/sessions/{sessionId}/commands", + .operation_id = "submitCommand", + .summary = "Run a command in one session", + .request = SubmitCommand, + .response = block_mod.Block, + .capability = "process.execute", + .tags = &.{"sessions"}, + }, + .{ + .method = .get, + .path = "/events", + .operation_id = "listEvents", + .summary = "Read the workspace event log from a sequence number", + .response = EventPage, + .capability = "fs.read", + .tags = &.{"events"}, + }, + .{ + .method = .post, + .path = "/approvals/{approvalId}", + .operation_id = "answerApproval", + .summary = "Answer a request that is waiting for a person", + .request = ApprovalAnswer, + .response = ApprovalResult, + .capability = "process.execute", + .tags = &.{"approvals"}, + }, +}; diff --git a/src/root.zig b/src/root.zig index 38f6c3c..4679a71 100644 --- a/src/root.zig +++ b/src/root.zig @@ -81,6 +81,10 @@ pub const workspace = struct { pub const service = @import("workspace/service.zig"); }; +pub const daemon = struct { + pub const interface = @import("daemon/interface.zig"); +}; + pub const editor = struct { pub const document = @import("editor/document.zig"); }; From c1938fc7594e5e46dd2befb1cf947f379511961f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 16:19:17 +0000 Subject: [PATCH 3/3] Make the first five minutes work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking a model needed a key and a four-line policy file. Nothing said so, and the failure mode was the expensive kind: nothing broken, nothing misspelt, just a refusal at the moment of asking. Three changes, in the order somebody hits them. `zag doctor` now says what is standing in the way. It checks the two halves against each other — is a credential set for a hosted connector, and does the policy in force allow reaching that connector's host — and names the missing one. The case worth catching is a key that is set while the built-in policy, which allows only a model on this computer, refuses the network: Next: ANTHROPIC_API_KEY is set, so this build can reach Anthropic — but the policy in force refuses to connect to api.anthropic.com, so a question would be stopped before it was sent. That is the policy doing its job, not a fault. Run "zag policy --init" to write a starter file that allows it. It reads whether the variable is set, never its value, and a test asserts the key never appears in the output. `zag policy --init` writes that file. This exists because the format defeated the person who wrote the parser: the first attempt in this session was `effect = "allow"` with a `capabilities` list, which is a reasonable guess and not the format. `zag policy` said exactly what was wrong, which was the difference between a puzzle and a dead end — but a good error message is a worse answer than a file that is already correct. The starter file is deliberately not the most permissive thing that works. It allows one provider by name, allows spending a key, and denies reading a key's value, so the first policy somebody owns already says no to something. A starter that allowed everything would teach that the policy is a formality to widen until the error stops. The host comes from the connector asked for, so `--init --provider ollama` writes localhost, not api.anthropic.com. It never overwrites. A policy file is what stands between an agent and someone's machine, and a command that replaced one because a flag was typed twice would be the worst possible way to widen a policy. An unknown connector is refused before anything is created, so a typo leaves no half-made workspace. Both are tested. The README documents the format: that the effect is the key rather than a value, that `because` is required and is what a person is shown, that a file with a mistake anywhere is refused whole, and the sixteen capability names. Two things found while doing this. `zag doctor` was still claiming this build could not stream an answer, could not run tool calls concurrently, and could not be configured beyond the policy file — all three shipped in the last six commits, and a doctor that lies about the build is worse than no doctor. And the starter policy is round-tripped in a test: written, parsed, and asserted to allow model.infer, deny credentials.read, deny git.push, allow the host it names and refuse another. An example that does not parse is worse than none, because it is the first thing somebody edits. Verified by hand end to end in an empty directory: doctor named the blocker, --init fixed it, doctor confirmed it, and the request reached Anthropic and came back 401 on a deliberately fake key. 613 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- README.md | 56 +++++++ evidence/conformance.jsonl | 34 ++-- src/ai/policy_file.zig | 125 ++++++++++++++ src/cli/main.zig | 333 +++++++++++++++++++++++++++++++++++-- 4 files changed, 521 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 045fa90..228cf8f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,62 @@ every value at its default. A setting this build cannot read is reported by name, with what was found and what is being used instead, and the terminal opens anyway — a mistake in that file never costs you a shell. +## Ask a model + +Two steps. Set a key, then write a policy: + +``` +export ANTHROPIC_API_KEY=... # or any connector; run "zag providers" +zag policy --init # writes .workspace/policy.toml +zag ask "why did the build fail?" +``` + +`zag doctor` says which of the two is missing. If a key is set but no policy +allows reaching the provider, it names the host and tells you what to run — +that is the case where nothing is broken, nothing is misspelt, and the only +symptom is a refusal at the moment of asking. + +With no policy file the built-in one is in force: it allows a model running on +your own computer and nothing else. So if you use Ollama, none of this is +needed. + +### The policy file + +`.workspace/policy.toml` is a list of rules. Each `[[rule]]` names **one** +effect — `allow`, `ask` or `deny` — and lists the capabilities it applies to: + +```toml +[[rule]] +id = "ask-a-model" +allow = ["model.infer"] +because = "This workspace may ask a model questions." + +[[rule]] +id = "reach-the-provider" +allow = ["network.connect"] +hosts = ["api.anthropic.com"] # scoped: nowhere else may be reached +because = "The model runs there." + +[[rule]] +id = "run-commands" +ask = ["process.execute"] # stops for a person, each time +because = "A person decides each command before it runs." +``` + +The effect is the key, not a value: it is `allow = [...]`, not +`effect = "allow"`. `because` is required and is what a person is shown when a +decision is made. A rule may be scoped by `hosts`, `paths` or `agents`; an +unscoped rule applies to every resource of that capability. + +Anything no rule allows is refused. A file with a mistake anywhere in it is +refused *whole* — nothing is granted by being written badly — and `zag policy` +says which rule and why. + +Capabilities: `fs.read`, `fs.write`, `fs.delete`, `process.execute`, +`process.signal`, `network.connect`, `network.listen`, `git.read`, +`git.commit`, `git.push`, `credentials.use`, `credentials.read`, `mcp.invoke`, +`container.start`, `remote.execute`, `model.infer`. + `zag-audit` is the second binary. It checks a repository against the standards it claims to meet, and nothing it does is needed to record work: diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 3cb7e74..a866909 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:11:14.124284685Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:3690ca104fd35192feb34c5c0335f751397ec1ce5ac0611359971724faa264fd","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag-audit check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T16:17:49.821517916Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/policy_file.zig b/src/ai/policy_file.zig index 12ddf61..b7847ba 100644 --- a/src/ai/policy_file.zig +++ b/src/ai/policy_file.zig @@ -452,3 +452,128 @@ test "the description reads as sentences, and never as capability names" { try testing.expect(std.mem.indexOf(u8, text, "network.connect") == null); try testing.expect(std.mem.indexOf(u8, text, "model.infer") == null); } + +/// A starter policy, written for a person who has just set an API key and +/// wants to ask a model a question. +/// +/// This exists because the format defeated the person who wrote the parser. The +/// first attempt at a policy file here was `effect = "allow"` with a +/// `capabilities` list — a reasonable guess, and not the format. `zag policy` +/// said exactly what was wrong, which was the difference between a puzzle and a +/// dead end, but a good error message is a worse answer than a file that is +/// already correct. +/// +/// It is deliberately not the most permissive thing that works. It allows one +/// provider by name and denies reading a credential's value, so the first +/// policy somebody owns is one that already says no to something. A starter +/// file that allowed everything would teach that the policy is a formality to +/// be widened until the error stops. +pub fn writeStarter(w: *std.Io.Writer, host: []const u8) std.Io.Writer.Error!void { + try w.print( + \\# What this workspace allows. Anything not allowed here is refused. + \\# + \\# Each [[rule]] names ONE effect — allow, ask or deny — and lists the + \\# capabilities it applies to. A rule with no effect does nothing, and + \\# this file is refused as a whole if any rule is unusable: nothing is + \\# granted by being written badly. + \\# + \\# Run "zag policy" to see what is in force, and "zag doctor" to see + \\# whether anything is missing. + \\ + \\[[rule]] + \\id = "ask-a-model" + \\allow = ["model.infer"] + \\because = "This workspace may ask a model questions." + \\ + \\[[rule]] + \\id = "reach-the-provider" + \\allow = ["network.connect"] + \\hosts = ["{s}"] + \\because = "The model runs there, and nowhere else may be reached." + \\ + \\[[rule]] + \\id = "spend-the-key" + \\allow = ["credentials.use"] + \\because = "A request may be signed with a saved key." + \\ + \\[[rule]] + \\id = "never-read-the-key" + \\deny = ["credentials.read"] + \\because = "Nothing needs the key's value, so nothing may see it." + \\ + \\[[rule]] + \\id = "read-this-workspace" + \\allow = ["fs.read"] + \\because = "A model may read the files in the workspace it was asked about." + \\ + \\# Uncomment to let a model run commands and change files. Read what it + \\# is about to do first: "ask" stops for a person, "allow" does not. + \\# + \\# [[rule]] + \\# id = "run-commands" + \\# ask = ["process.execute", "fs.write"] + \\# because = "A person decides each command before it runs." + \\ + , .{host}); +} + +test "the starter policy this build writes is one this build can read" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The whole point of the starter file is that it is already correct. One + // that does not parse is worse than none, because it is the first thing + // somebody edits and it teaches them the format is broken. + var out: std.Io.Writer.Allocating = .init(arena); + try writeStarter(&out.writer, "api.anthropic.com"); + + const result = try parse(arena, out.written()); + if (!result.usable()) { + for (result.problems) |problem| std.debug.print("starter policy problem: {s}\n", .{problem.message}); + } + try testing.expect(result.usable()); + try testing.expectEqual(@as(usize, 0), result.problems.len); + + var engine = policy_mod.Engine.init(arena, result.policy.?, 7); + const context = starterContext(); + + // It allows what asking a model needs, and refuses to read the key's value. + try testing.expectEqual(Effect.allow, (try engine.decide(.{ .capability = .@"model.infer" }, context)).effect); + try testing.expectEqual(Effect.deny, (try engine.decide(.{ .capability = .@"credentials.read" }, context)).effect); + // And it is not a blank cheque: nothing said it could push to a remote. + try testing.expectEqual(Effect.deny, (try engine.decide(.{ .capability = .@"git.push" }, context)).effect); +} + +fn starterContext() policy_mod.Context { + const idmod = @import("../core/id.zig"); + var gen: idmod.Generator = .init(9, 1_788_000_000_000); + return .{ + .actor = gen.next(idmod.ActorId), + .session = gen.next(idmod.SessionId), + .agent = gen.next(idmod.AgentId), + .now = @import("../core/time.zig").Timestamp.parseIso("2026-09-07T12:00:00Z") catch unreachable, + }; +} + +test "the starter policy scopes the network to the host it names" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var out: std.Io.Writer.Allocating = .init(arena); + try writeStarter(&out.writer, "api.anthropic.com"); + var engine = policy_mod.Engine.init(arena, (try parse(arena, out.written())).policy.?, 8); + const context = starterContext(); + + try testing.expectEqual(Effect.allow, (try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = "api.anthropic.com" }, + }, context)).effect); + // Somewhere else is refused, which is the part that makes writing the host + // down worth doing at all. + try testing.expectEqual(Effect.deny, (try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = "example.invalid" }, + }, context)).effect); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 7740b2e..facff43 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -82,7 +82,7 @@ pub const help_text = \\ knowledge Show the knowledge under .workspace/, and what is overdue. \\ term Open a shell in a terminal that records what you do. \\ providers List the model connectors and say which credentials are set. - \\ policy Show the policy in force, and where it was read from. + \\ policy [--init] Show the policy in force, or write one to start from. \\ ask Ask a model, through the policy. It can use tools, one decision each. \\ why [n] Show what an event depended on, and what it went on to affect. \\ secrets Show what would be taken out of these files before recording them. @@ -96,6 +96,7 @@ pub const help_text = \\ --turns How many times a model may be asked in one run. \\ --raw Start the shell with no added prompt marks. \\ --stream Print a model's answer as it arrives, not when it finishes. + \\ --init Write a starter policy file. Used with "zag policy". \\ --approve-truncate Apply the recovery plan after preserving the original log. \\ \\To audit a repository against the standards it claims to meet, use @@ -114,6 +115,8 @@ const Options = struct { /// Print a model's answer as it arrives rather than when it is finished. stream: bool = false, approve_truncate: bool = false, + /// Write a starter policy file rather than showing the policy in force. + init: bool = false, provider: []const u8 = "", model: []const u8 = "", turns: []const u8 = "", @@ -126,6 +129,10 @@ fn approvalOptionIsValid(command: Command, options: Options) bool { return !options.approve_truncate or command == .recover; } +fn initOptionIsValid(command: Command, options: Options) bool { + return !options.init or command == .policy; +} + fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { var options: Options = .{}; var positional: std.ArrayList([]const u8) = .empty; @@ -147,6 +154,10 @@ fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { options.json = true; continue; } + if (std.mem.eql(u8, arg, "--init")) { + options.init = true; + continue; + } if (std.mem.eql(u8, arg, "--raw")) { options.raw = true; continue; @@ -231,6 +242,11 @@ pub fn main(init: std.process.Init) !u8 { try w.flush(); return 2; } + if (!initOptionIsValid(command, options)) { + try w.writeAll("--init applies only to \"zag policy\". Nothing was changed.\n"); + try w.flush(); + return 2; + } var environment: std.ArrayList([]const u8) = .empty; { @@ -264,7 +280,7 @@ fn run( try w.print("zag {s}\n", .{zag.version}); break :blk 0; }, - .doctor => try doctor(arena, io, w, options), + .doctor => try doctor(arena, io, w, options, environment), .events => try events(arena, io, w, options), .objects => try contentObjects(arena, io, w, options), @@ -288,7 +304,13 @@ fn run( // Commands // --------------------------------------------------------------------------- -fn doctor(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { +fn doctor( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, + environment: []const []const u8, +) !u8 { const builtin = @import("builtin"); const loaded = try LoadedPolicy.fromWorkspace(arena, io, options.root); const written_policy = loaded.path != null and loaded.problems.len == 0; @@ -317,19 +339,18 @@ fn doctor(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Opti zag.ai.toolschema.offers.len, }); try w.writeAll(" Explain a failure from the record: yes, with zag why\n"); - try w.writeAll(" Plain-language checks: yes\n"); - try w.writeAll(" Terminology and metadata registry: yes\n"); - try w.writeAll(" Standards registry and evidence: yes\n"); - try w.writeAll(" Generated schemas and vocabulary: yes\n\n"); + try w.writeAll(" Read an answer as it arrives: yes, with --stream\n"); + try w.writeAll(" Run independent tool calls at once: yes, in waves that share nothing\n"); + try w.writeAll(" Audit this repository: yes, in the zag-audit binary\n\n"); try w.writeAll("What this build cannot do yet\n"); try w.writeAll(" Draw its own window. The renderer is not written; the accessibility tree it must publish is.\n"); try w.writeAll(" Talk to a language server. The editor surfaces are not written.\n"); - try w.writeAll(" Read an answer as it arrives. A model request waits for the whole reply.\n"); - try w.writeAll(" Run more than one tool call at a time. A turn does them one after another.\n"); try w.writeAll(" Run on Windows or macOS terminals. The pseudoterminal layer is Linux only so far.\n"); try w.writeAll(" Split the screen. There are no tabs, panes or splits yet.\n"); - try w.writeAll(" Be configured beyond the policy file. There are no key bindings and no theme file yet.\n\n"); + try w.writeAll(" Draw the colours or bind the keys a settings file asks for. It reads them and reports on them.\n\n"); + + try writeNextStep(arena, w, loaded, written_policy, environment); const system = try zag.knowledge.vocabulary.build(arena); try w.print("The vocabulary holds {d} concepts.\n", .{system.count()}); @@ -693,6 +714,8 @@ fn showPolicy( w: *std.Io.Writer, options: Options, ) !u8 { + if (options.init) return writeStarterPolicy(arena, io, w, options); + const loaded = try LoadedPolicy.fromWorkspace(arena, io, options.root); if (loaded.path) |path| { @@ -720,6 +743,149 @@ fn showPolicy( return 0; } +/// Say what is standing between this workspace and asking a model. +/// +/// `zag doctor` listed what the build could do and left the person to work out +/// why their own machine would not do it. The interesting case is the one that +/// costs an afternoon: a key is set, the connector exists, and the policy in +/// force — usually the built-in one, which allows only a model on this +/// computer — refuses the network. Nothing is broken, nothing is misspelt, and +/// the only sign is a refusal at the moment of asking. +/// +/// So this checks the two halves against each other and names the missing one. +/// A credential's value is never read here, only whether the variable is set: +/// the point is to say "the key is there", not to look at it. +fn wallClockOrZero() zag.core.time.Timestamp { + // The decision here is only about a host, and no rule in a starter policy + // is time-scoped, so a fixed instant keeps `zag doctor` from needing a + // clock it does not otherwise use. + return .{ .ns = 0 }; +} + +fn writeNextStep( + arena: std.mem.Allocator, + w: *std.Io.Writer, + loaded: LoadedPolicy, + written_policy: bool, + environment: []const []const u8, +) !void { + const env: Environment = .{ .entries = environment }; + + // Which connectors could actually be used: a hosted one needs its key set, + // a local one needs nothing. + var keyed: ?zag.ai.catalog.Connector = null; + var local: ?zag.ai.catalog.Connector = null; + for (zag.ai.catalog.connectors) |connector| { + if (connector.locality == .local) { + if (local == null) local = connector; + continue; + } + if (connector.keyVariable.len == 0) continue; + if (keyed == null and Environment.lookup(@constCast(&env), connector.keyVariable) != null) keyed = connector; + } + + if (loaded.problems.len > 0) { + try w.writeAll("Next: this workspace has a policy file that cannot be used, so nothing in\n"); + try w.writeAll("it is in force. Run \"zag policy\" to see what is wrong with it.\n\n"); + return; + } + + const connector = keyed orelse { + try w.writeAll("Next: no credential is set for any hosted connector, so a model on this\n"); + if (local) |one| { + try w.print("computer is the one this build can reach. Start {s} and run \"zag ask\",\n", .{one.name}); + try w.writeAll("or set a key and run \"zag providers\" to see which one it belongs to.\n\n"); + } else { + try w.writeAll("computer would be the way in. Run \"zag providers\" to see the connectors.\n\n"); + } + return; + }; + + // A key is set. Does the policy in force let it reach that host? + var engine = zag.ai.policy.Engine.init(arena, loaded.policy, 0); + const host = connector.endpoint("").host(); + const reachable = engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = host }, + }, .{ + .actor = .{ .raw = .{ .bytes = @splat(0) } }, + .session = .{ .raw = .{ .bytes = @splat(0) } }, + .agent = .{ .raw = .{ .bytes = @splat(0) } }, + .now = wallClockOrZero(), + }) catch null; + + if (reachable) |decision| { + if (decision.isAllowed()) { + try w.print("Next: {s} is set and the policy allows reaching {s}.\n", .{ connector.keyVariable, host }); + try w.writeAll("Run \"zag ask\" to ask a question, or \"zag term\" to open a recorded shell.\n\n"); + return; + } + } + + // The interesting case, and the reason this function exists. + try w.print("Next: {s} is set, so this build can reach {s} — but the policy in force\n", .{ connector.keyVariable, connector.name }); + try w.print("refuses to connect to {s}, so a question would be stopped before it was\n", .{host}); + try w.writeAll("sent. That is the policy doing its job, not a fault.\n"); + if (written_policy) { + try w.print("Add {s} to a network.connect rule in {s}.\n\n", .{ host, zag.ai.policy_file.default_path }); + } else { + try w.writeAll("Run \"zag policy --init\" to write a starter file that allows it.\n\n"); + } +} + +/// Write a starter policy file for a person who has a key and no policy. +/// +/// It never overwrites. A policy file is the thing standing between an agent +/// and someone's machine, and a command that silently replaced one because a +/// flag was typed twice would be the worst possible way to widen a policy. +fn writeStarterPolicy( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, +) !u8 { + const path = try std.fmt.allocPrint(arena, "{s}/{s}", .{ options.root, zag.ai.policy_file.default_path }); + + // Checked by reading, not by a flag on the open: a workspace where the + // policy exists but cannot be read is not one to write into either. + if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20))) |_| { + try w.print("{s} already exists, so nothing was written.\n", .{path}); + try w.writeAll("Run \"zag policy\" to see what is in force, and edit that file to change it.\n"); + return 1; + } else |err| switch (err) { + error.FileNotFound => {}, + else => { + try w.print("{s} exists but could not be read, so nothing was written.\n", .{path}); + return 1; + }, + } + + // The host the starter file names comes from the connector the person + // asked for, so the file is right for the provider they actually use + // rather than right for the one this tool happened to list first. + const connector_name = if (options.provider.len > 0) options.provider else "anthropic"; + const connector = zag.ai.catalog.find(connector_name) orelse { + try w.print("\"{s}\" is not a connector this build knows. Run \"zag providers\" to see them.\n", .{connector_name}); + return 2; + }; + + var text: std.Io.Writer.Allocating = .init(arena); + try zag.ai.policy_file.writeStarter(&text.writer, connector.endpoint("").host()); + + const directory = try std.fmt.allocPrint(arena, "{s}/{s}", .{ options.root, zag.workspace.service.workspace_directory }); + std.Io.Dir.cwd().createDirPath(io, directory) catch {}; + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = text.written() }) catch { + try w.print("zag could not write {s}.\n", .{path}); + return 1; + }; + + try w.print("Wrote {s} for {s}.\n\n", .{ path, connector.name }); + try w.writeAll("It allows asking a model, reaching that one host, and spending a saved\n"); + try w.writeAll("key without reading it. Everything else is refused. Read it before you\n"); + try w.writeAll("widen it, and run \"zag policy\" to see what is in force.\n"); + return 0; +} + /// Ask a model one question, through the policy. /// /// Nothing here decides anything. The connector is chosen, the policy is read @@ -1895,3 +2061,150 @@ test "the record says who wrote it, and tells a person from the workbench" { try testing.expect(!workbench.id.eql(expected.id)); try testing.expectEqual(zag.data.provenance.ProducerKind.system, workbench.kind); } + +test "a starter policy is written once, and never over one that exists" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const root = ".zig-cache/tmp/policy-init-test"; + std.Io.Dir.cwd().deleteTree(io, root) catch {}; + defer std.Io.Dir.cwd().deleteTree(io, root) catch {}; + try std.Io.Dir.cwd().createDirPath(io, root); + + var out: std.Io.Writer.Allocating = .init(arena); + const wrote = try writeStarterPolicy(arena, io, &out.writer, .{ .root = root, .init = true }); + try testing.expectEqual(@as(u8, 0), wrote); + + // What it wrote has to be what this build reads, or the command has handed + // somebody a file that fails the moment they use it. + const path = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, zag.ai.policy_file.default_path }); + const source = try std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)); + const parsed = try zag.ai.policy_file.parse(arena, source); + try testing.expect(parsed.usable()); + + // A policy file is what stands between an agent and someone's machine. A + // second --init, whatever the reason for it, must not touch it. + var again: std.Io.Writer.Allocating = .init(arena); + const second = try writeStarterPolicy(arena, io, &again.writer, .{ .root = root, .init = true }); + try testing.expectEqual(@as(u8, 1), second); + try testing.expect(std.mem.indexOf(u8, again.written(), "already exists") != null); + + const after = try std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)); + try testing.expectEqualStrings(source, after); +} + +test "a starter policy for a connector nobody has heard of writes nothing at all" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const root = ".zig-cache/tmp/policy-init-unknown"; + std.Io.Dir.cwd().deleteTree(io, root) catch {}; + defer std.Io.Dir.cwd().deleteTree(io, root) catch {}; + try std.Io.Dir.cwd().createDirPath(io, root); + + var out: std.Io.Writer.Allocating = .init(arena); + const status = try writeStarterPolicy(arena, io, &out.writer, .{ + .root = root, + .init = true, + .provider = "no-such-connector", + }); + try testing.expectEqual(@as(u8, 2), status); + + // Refused before anything was created, so a typo does not leave a + // half-made workspace behind. + const path = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, zag.ai.policy_file.default_path }); + try testing.expectError(error.FileNotFound, std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20))); +} + +test "the starter policy names the host of the connector that was asked for" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const root = ".zig-cache/tmp/policy-init-local"; + std.Io.Dir.cwd().deleteTree(io, root) catch {}; + defer std.Io.Dir.cwd().deleteTree(io, root) catch {}; + try std.Io.Dir.cwd().createDirPath(io, root); + + var out: std.Io.Writer.Allocating = .init(arena); + _ = try writeStarterPolicy(arena, io, &out.writer, .{ .root = root, .init = true, .provider = "ollama" }); + + const path = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, zag.ai.policy_file.default_path }); + const source = try std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)); + // A file for a model on this machine that allowed api.anthropic.com would + // be a file written for somebody else's setup. + try testing.expect(std.mem.indexOf(u8, source, "localhost") != null); + try testing.expect(std.mem.indexOf(u8, source, "api.anthropic.com") == null); +} + +test "doctor names the policy as the blocker when a key is set and the host is refused" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The case worth catching: nothing is broken, nothing is misspelt, and the + // only symptom is a refusal at the moment of asking. The built-in policy + // allows a model on this computer and nothing else. + const loaded: LoadedPolicy = .{ + .policy = (try zag.ai.policy_file.parse(arena, zag.ai.policy_file.local_models_only)).policy.?, + .path = null, + .problems = &.{}, + }; + var out: std.Io.Writer.Allocating = .init(arena); + try writeNextStep(arena, &out.writer, loaded, false, &.{"ANTHROPIC_API_KEY=sk-ant-whatever"}); + + const text = out.written(); + try testing.expect(std.mem.indexOf(u8, text, "ANTHROPIC_API_KEY is set") != null); + try testing.expect(std.mem.indexOf(u8, text, "api.anthropic.com") != null); + // It says what to do, not just what is wrong. + try testing.expect(std.mem.indexOf(u8, text, "zag policy --init") != null); + // And it never prints the key. + try testing.expect(std.mem.indexOf(u8, text, "sk-ant-whatever") == null); +} + +test "doctor says nothing is in the way when the policy allows the host" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var starter: std.Io.Writer.Allocating = .init(arena); + try zag.ai.policy_file.writeStarter(&starter.writer, "api.anthropic.com"); + const loaded: LoadedPolicy = .{ + .policy = (try zag.ai.policy_file.parse(arena, starter.written())).policy.?, + .path = ".workspace/policy.toml", + .problems = &.{}, + }; + + var out: std.Io.Writer.Allocating = .init(arena); + try writeNextStep(arena, &out.writer, loaded, true, &.{"ANTHROPIC_API_KEY=sk-ant-whatever"}); + try testing.expect(std.mem.indexOf(u8, out.written(), "the policy allows reaching") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "sk-ant-whatever") == null); +} + +test "doctor points at a broken policy file before anything else" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A file that cannot be used grants nothing, so saying "your key is fine" + // would send somebody looking in the wrong place. + const loaded: LoadedPolicy = .{ + .policy = (try zag.ai.policy_file.parse(arena, zag.ai.policy_file.local_models_only)).policy.?, + .path = ".workspace/policy.toml", + .problems = &.{.{ .rule = 1, .message = "This rule does not say allow, ask or deny, so it would do nothing." }}, + }; + var out: std.Io.Writer.Allocating = .init(arena); + try writeNextStep(arena, &out.writer, loaded, false, &.{"ANTHROPIC_API_KEY=sk-ant-whatever"}); + try testing.expect(std.mem.indexOf(u8, out.written(), "cannot be used") != null); +}