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..228cf8f 100644 --- a/README.md +++ b/README.md @@ -72,27 +72,91 @@ 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. +## 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: + +``` +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..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 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: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/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..facff43 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,42 +78,36 @@ 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. \\ 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. \\ \\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. \\ --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 + \\"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. @@ -157,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 = "", @@ -169,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; @@ -190,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; @@ -203,10 +171,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 }, @@ -278,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; { @@ -311,18 +280,8 @@ fn run( try w.print("zag {s}\n", .{zag.version}); 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), + .doctor => try doctor(arena, io, w, options, environment), + .events => try events(arena, io, w, options), .objects => try contentObjects(arena, io, w, options), .recover => try recoverWorkspace(arena, io, w, options), @@ -330,8 +289,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), @@ -347,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; @@ -376,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()}); @@ -397,393 +359,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"); @@ -1139,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| { @@ -1166,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 @@ -1885,940 +1605,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 +1657,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 +1666,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 +1771,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(); @@ -3331,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); +} 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/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/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..4679a71 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 { @@ -85,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"); }; @@ -144,10 +144,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 {