okf-cli 0.4.0.0 → 0.5.0.0
raw patch · 15 files changed
+2548/−88 lines, 15 filesdep ~okf-corePVP ok
version bump matches the API change (PVP)
Dependency ranges changed: okf-core
API changes (from Hackage documentation)
+ Okf.Cli: Computations :: ComputationsOptions -> Command
+ Okf.Cli: ComputationsOptions :: !FilePath -> ComputationsOptions
+ Okf.Cli: ProfileDocument :: ProfileDocumentOptions -> ProfileCommand
+ Okf.Cli: ProfileDocumentOptions :: !Maybe Text -> !Maybe Text -> !Maybe FilePath -> !Maybe FilePath -> !Bool -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> ProfileDocumentOptions
+ Okf.Cli: Sources :: SourcesOptions -> Command
+ Okf.Cli: Trust :: TrustOptions -> Command
+ Okf.Cli: [computationOnly] :: ShowOptions -> !Bool
+ Okf.Cli: [generatedAt] :: ProfileDocumentOptions -> !Maybe Text
+ Okf.Cli: [generatedBy] :: ProfileDocumentOptions -> !Maybe Text
+ Okf.Cli: [okfVersion] :: ProfileDocumentOptions -> !Maybe Text
+ Okf.Cli: [outputPath] :: ProfileDocumentOptions -> !Maybe FilePath
+ Okf.Cli: [timestamp] :: ProfileDocumentOptions -> !Maybe Text
+ Okf.Cli: computationReport :: [Concept] -> [Text]
+ Okf.Cli: data ComputationsOptions
+ Okf.Cli: data ProfileDocumentOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ComputationsOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileDocumentOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.SourcesOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.TrustOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ComputationsOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileDocumentOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.SourcesOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.TrustOptions
- Okf.Cli: IndexOptions :: !FilePath -> !Bool -> IndexOptions
+ Okf.Cli: IndexOptions :: !FilePath -> !Bool -> !Maybe Text -> IndexOptions
- Okf.Cli: ShowOptions :: !Maybe FilePath -> !Maybe Text -> !Maybe FilePath -> ShowOptions
+ Okf.Cli: ShowOptions :: !Maybe FilePath -> !Maybe Text -> !Maybe FilePath -> !Bool -> ShowOptions
- Okf.Cli: [write] :: IndexOptions -> !Bool
+ Okf.Cli: [write] :: ProfileDocumentOptions -> !Bool
Files
- CHANGELOG.md +139/−0
- help/computations.md +88/−0
- help/format.md +78/−6
- help/graph.md +48/−0
- help/ids.md +80/−0
- help/index.md +62/−0
- help/log.md +73/−0
- help/okf.md +33/−10
- help/profiles.md +97/−1
- help/trust.md +89/−0
- help/validation.md +70/−14
- okf-cli.cabal +3/−3
- src/Okf/Cli.hs +953/−36
- src/Okf/Cli/Help.hs +26/−1
- test/Main.hs +709/−17
CHANGELOG.md view
@@ -7,6 +7,145 @@ ## [Unreleased] +## [0.5.0.0] - 2026-08-01++### Added++- `okf trust BUNDLE` reports every concept's derived trust tier, `status`, and+ staleness as one aligned row per concept. A tier is computed from `verified`+ on each run and never read from the bundle.+- `okf sources BUNDLE` lists the OKF v0.2 provenance each concept records, with+ the credibility signals `author`, `usage_count`, and `last_modified`, and the+ effective usage window per entry. Concepts with no `sources` are skipped, and+ entries are never sorted or ranked by `usage_count`.+- `okf index BUNDLE --write --okf-version MAJOR.MINOR` declares an OKF version+ in the bundle root `index.md`. Without the flag, an existing declaration is+ preserved and an absent one stays absent — previously, regenerating indexes+ silently destroyed a declaration.+- `okf show` prints the v0.2 families a concept carries — `generated`, the+ derived `trust` tier, the latest `verified` date, `status`, `stale_after` with+ its staleness verdict, and a `sources` count.+- `okf validate` renders the new core diagnostics, including the join between a+ body's footnote labels and its `sources` ids in both directions.+- `okf validate --profile` reports deviations inside an object-valued+ frontmatter key, at paths such as `generated.by`, and refuses a descriptor that+ pairs `objectFields` with an explicit scalar or list cardinality:+ `objectFields at generated cannot be combined with cardinality scalar`.+ `okf profile document` renders the new rule kind as an `- Object fields:`+ bullet, which changes generated output for every profile; the committed+ `examples/postgresql-profile/` bundle is regenerated accordingly.+- `okf computations BUNDLE` lists every attested computation a bundle declares,+ one aligned row each, in the house style of `okf trust` and `okf sources`: the+ concept ID, the `runtime`, the `parameters`, where the computation lives, and+ which of §10.2's two run-and-check halves the concept declares. Selection is on+ the `type` frontmatter value being exactly `Attested Computation`. Every column+ restates frontmatter and none says anything about a run — a receipt and a+ verdict live outside the bundle and okf never executes or attests anything. An+ absent value prints as `(no runtime)`, `(no computation)`, `(2 computations)`,+ or `(neither)` rather than as a blank cell, so the report hides nothing that+ `okf validate --strict` reports. A bundle with no computations prints nothing+ and exits 0.+- `okf show CONCEPT --computation` prints a concept's computation and nothing+ else, in whichever of §10.3's two forms its producer chose — reading the file+ named by `computation` rather than printing its path. Offering none, or more+ than one, exits non-zero rather than guessing.+- `okf show` renders the §10.2 contract in the specification's own field order,+ printing each line only when the concept declares it.+- `examples/ddd-ordering` ships a worked attested computation at+ `computations/order-total.md`, with a `references/skills/` executor and a+ `references/attesters/` attester, and a test asserting it validates strictly.+- `okf profile document --generated-by ACTOR` sets the actor recorded in+ `generated.by` on every generated page. Accepts any OKF v0.2 actor shape:+ `<producer>/<version>`, `human:<id>`, or `process:<id>`.+- `okf profile document --generated-at RFC3339` records a timestamp in+ `generated.at`. Omitted entirely when not given, because generation never+ reads the clock.+- `okf profile document --okf-version MAJOR.MINOR` declares the OKF version in+ the generated bundle's root index, spelled and validated exactly as the flag+ of the same name on `okf index`. Omitting it preserves any declaration the+ destination already carries rather than deleting one.+- `okf validate --profile` reports a bundle whose `okf_version` declaration does+ not meet the profile's `requireBundleVersion`, as `bundle does not declare+ okf_version; this profile requires 0.2 or later` or `bundle declares+ okf_version 0.1; …`. Advisory like every other profile deviation and fatal+ under `--profile-enforce`. It is the one violation that names no concept.+- `okf profile show` prints a `requireBundleVersion:` line, and `--json` gains+ the matching key.++### Changed++- **`okf profile show` renders `objectFields`.** It printed `elementFields` and+ not its counterpart, so a profile constraining the members of a mapping-valued+ key displayed that rule nowhere. The gap was not hypothetical: the shipped+ `docs/profiles/okf-v0-2.dhall` constrains the members of `generated`,+ `verified`, and `usage_window`, and none of it was visible. The two nested+ shapes now print together, `objectFields` first, matching+ `okf profile document`, which already rendered its object-fields bullet in that+ position. Every field rule gains at least an `objectFields: (none)` line, so+ the output of this command changes for every profile.+- **The embedded `okf help` topics describe OKF v0.2.** `okf help format` listed+ `timestamp` among current frontmatter fields and `# Citations` as a+ conventional body heading, and named no v0.2 family at all; `okf help+ validation` required `timestamp` under `--strict` and opened its conformance+ section with "OKF v0.1 conformance"; and `okf help okf` asserted the tool+ tracks the v0.1 specification while listing five of fourteen commands. All+ three are current. `format` gains the six v0.2 families, an ATTESTED+ COMPUTATIONS section, the `references/` convention, and the §6.2 path grammar;+ `validation` gains the strict-mode diagnostics, a VERSION DECLARATION section,+ and one statement of why every optional-family check is strict-only.+- `okf validate --strict` reports a path in a frontmatter value that names a file+ the bundle does not hold, which no check saw before. Only `resource` and the+ three attested-computation path fields; `sources[].resource` is deliberately+ exempt because §5.1 sanctions a scope descriptor there.+- `okf index` lists a directory's non-Markdown files, so a `references/attesters/`+ directory holding only `.py` files no longer generates a one-byte `index.md`.+- `okf validate --profile` resolves a `path` rule against every file in the+ bundle rather than only `.md` concepts.+- A missing member of a nested record now carries the member's `description`+ prose in parentheses — `missing profile-required field: generated.by (Who or+ what produced this content.)` — as a missing top-level key already did. This+ applies to list-element members too, and adds nothing where a member declares+ no description.+- **`okf validate` names a declared OKF version in its success line**:+ `OK: 4 concepts (okf_version 0.2)`. The suffix appears only when the bundle's+ root `index.md` declares a version, so a bundle that declares nothing prints+ exactly `OK: N concepts` as before.+- **Strict validation asks for `generated` rather than `timestamp`.** It falls+ back to a legacy `timestamp`, so nothing that passed before fails now, but the+ message for a concept with neither changes to+ `missing generated field (or legacy timestamp)`. In a bundle that declares+ `okf_version: "0.2"`, a concept still carrying `timestamp` is additionally+ reported under `--strict`; an undeclared bundle never is.+- **`okf log --check-stale` and `okf validate --log-enforce` read `generated.at`+ first**, falling back to `timestamp`, and the advisory says `generated date`+ rather than `timestamp date`.+- The shipped `docs/profiles/postgresql.dhall` now requires its bundles to+ declare `okf_version` at 0.2 or later, since its own rules are written for+ v0.2 and `examples/postgresql-sample` already declares it. The shipped+ `docs/profiles/okf-v0-2.dhall` deliberately does not: §12 makes the+ declaration a MAY, and a format-level reference profile that demanded one+ would advise the opposite of the specification.+- `okf profile document` now writes `generated.by: process:okf-profile-document`+ on every page by default, so default output passes `okf validate --strict`+ without `--timestamp`. The default actor is deliberately version-free: a+ version-bearing one would change every generated byte on every okf release and+ break the drift check that committed documentation exists to provide.+- `--timestamp` still writes the superseded OKF v0.1 `timestamp` key and is+ unchanged, but is now needed only when producing a v0.1 bundle.+- Requires `okf-core ^>=0.5.0.0` with the OKF v0.2 core semantics, the attested+ computation contract, the path-valued frontmatter rules, and the+ profile-declared bundle version requirement.++### Fixed++- `okf-cli-test` passes from the released sdist. Nine tests assert against+ artifacts committed to the okf repository — `docs/profiles/`, `examples/`, and+ `okf-core`'s fixtures — none of which cabal will package into `okf-cli`'s+ tarball, because they sit outside the package tree. They previously aborted the+ whole suite on the first missing file, so `cabal test` failed for anyone+ building from Hackage, nixpkgs included. Each now reports `SKIP` when the+ repository tree is absent and runs unchanged inside it.+ ## [0.4.0.0] - 2026-07-30 ### Added
+ help/computations.md view
@@ -0,0 +1,88 @@+ATTESTED COMPUTATIONS ON THE COMMAND LINE++A concept whose type is exactly "Attested Computation" carries a sanctioned way+to compute a value, so a consumer can confirm a number came from running the+blessed computation rather than from an agent improvising its own query. See+"okf help format" for the frontmatter contract; this topic is the tooling.++LISTING A BUNDLE'S COMPUTATIONS++ okf computations BUNDLE++ One aligned row per computation, ordered by concept ID:++ computations/order-total postgres order_id (uuid, required) inline executor + attester++ The five columns are the concept ID, the runtime, the parameters, where the+ computation lives, and which of the two run-and-check halves the concept+ declares. Every one restates frontmatter and nothing else.++ Selection is on the type value being exactly "Attested Computation". A Metric+ that happens to carry a runtime key does not appear, and a computation+ declaring no contract field at all still does.++ A bundle with no attested computations prints nothing and exits 0. An empty+ report is not an error.++WHAT THE PARENTHESISED CELLS MEAN++ An absent value prints as a phrase rather than as an empty cell, so the report+ hides nothing that okf validate --strict would report:++ computations/margin (no runtime) year (integer, required) inline (neither)+ computations/no-computation bigquery (no parameters) (no computation) (neither)+ computations/two-blocks bigquery (no parameters) (2 computations) (neither)++ (no runtime) The one field the specification marks REQUIRED for this+ type is missing.+ (no computation) and (2 computations) are the two ways the exactly-one rule+ breaks: a computation is either one code block under+ "# Computation" or a computation path, never both, never+ neither.+ (neither) The concept names no executor and no attester. Legitimate:+ section 10.2 marks neither half REQUIRED. The column reads+ "executor + attester", "executor", "attester", or+ "(neither)", and all four are conformant.++ A parameter with no declared type prints as its bare name.++PRINTING ONE COMPUTATION++ okf show BUNDLE CONCEPT_ID --computation++ Prints the computation and nothing else -- no metadata, no heading, no body+ prose:++ SELECT SUM(quantity * unit_amount_minor) AS total_minor+ FROM order_lines+ WHERE order_id = :order_id++ The flag prints either form. When the concept names a file with the+ computation key, okf resolves the path against the bundle and prints the+ file's contents, so a caller never has to know which form the producer chose.++ A concept offering no computation, or more than one, exits non-zero with a+ message on stderr rather than guessing which to print. The flag works with+ interactive selection and resolves a document ID by the same rules as show+ without it.++WHAT okf DOES NOT DO++ okf never executes a computation and never attests one. Printing one is not+ executing it, and no column of okf computations says whether a computation+ would attest cleanly -- none can. A receipt is what a run returns and a+ verdict is what an attester produces from it; both are runtime artifacts that+ live outside the bundle.++ "executor + attester" means the concept names the two things a consumer would+ need in order to run and check it, not that either has ever been run.++ The trust and staleness reported by okf trust are a different question again:+ they say whether the DEFINITION still matches policy, which a computation can+ pass while any individual run fails, and the reverse.++SEE ALSO++ okf help format The contract keys and the exactly-one rule.+ okf help validation Which computation problems --strict reports.+ okf help trust Trust tiers, staleness, and recorded provenance.
help/format.md view
@@ -42,20 +42,83 @@ resource Optional. Canonical URI for the underlying asset (absent for purely abstract concepts). tags Optional. List of short categorization strings.- timestamp Optional. ISO 8601 datetime of last meaningful change. + These OKF v0.2 families are all optional, and okf reads every one:++ generated Who produced this content and when: a mapping with a REQUIRED+ "by" actor and an optional "at" timestamp.+ verified Independent confirmation that the content is accurate: a+ mapping, or a list of them. Distinct from generated -- who+ wrote a concept need not be who confirmed it.+ status Lifecycle state: draft, stable, deprecated, or superseded.+ Absence means stable.+ stale_after Calendar date after which the content should be re-confirmed.+ Advisory; okf reports it but never refuses a stale concept.+ sources What the content was derived from, one entry per source, with+ optional credibility signals such as author and usage_count.+ usage_window The period those sources were observed over.++ timestamp is the superseded v0.1 spelling of generated. okf still reads it+ and falls back to it, but new documents should write generated.+ Producers may add any other keys; the parser preserves unknown keys as extension data and never rejects a document for carrying them. + A bundle may declare its format version in the root index.md frontmatter as+ okf_version: "0.2". An undeclared bundle is read with v0.1 fallbacks.++ATTESTED COMPUTATIONS++ A concept whose type is exactly "Attested Computation" carries not just what+ a value means but a sanctioned way to compute it, so a consumer can confirm+ a number came from running the blessed computation rather than from an agent+ improvising its own query. Five contract keys describe it:++ runtime REQUIRED for this type. What would run the computation, and+ therefore what a parameter means -- a SQL bind variable, a+ dbt var, a Python argument.+ parameters The typed named holes an agent may fill, each with a name and+ optionally a type and whether it is required.+ computation A path to a file holding the computation, used instead of an+ inline block.+ executor Run instructions plus the receipt fields a run must return.+ attester Deterministic, no-LLM code that inspects a receipt and+ returns a verdict.++ The computation is provided in exactly one of two ways: a single code block+ in the body under a "# Computation" heading, or the computation key naming a+ file. Both, or neither, is an authoring error okf validate --strict reports.++ Concepts that need the value -- a Metric, a table -- link to the computation+ with an ordinary Markdown link, so each attests on its own run.++ okf never executes a computation and never attests one. A receipt and a+ verdict are runtime artifacts that live outside the bundle; okf records the+ computation and the means to check it, and stops there. Use okf computations+ to list a bundle's computations and okf show CONCEPT --computation to print+ one, in whichever of the two forms its producer chose.+ CONVENTIONAL BODY HEADINGS The body is plain Markdown with no required sections. These headings carry conventional meaning when applicable: - # Schema Columns/fields of a structured asset.- # Examples Concrete usage examples.- # Citations Numbered external sources backing claims in the body.+ # Schema Columns/fields of a structured asset.+ # Examples Concrete usage examples.+ # Computation The sanctioned computation of an Attested Computation. + # Citations is the superseded v0.1 heading for external sources backing+ claims in the body; v0.2 records those in the sources frontmatter family and+ cites them from the body with Markdown footnotes.++THE references/ CONVENTION++ Executors, attesters, and computation files are conventionally kept under a+ references/ directory. This is a naming convention, not a requirement, and+ the directory is not special to okf: a .md file under it is an ordinary+ concept and needs a type, while a .py or .sql file under it is not a concept+ at all and is only ever the target of a path.+ LINKS Concepts relate to each other through standard Markdown links to .md files@@ -69,7 +132,16 @@ A link to a .md concept that does not exist is a dangling reference: graph ignores it, but okf validate reports it (see "okf help validation"). + A path in a frontmatter value follows the same grammar. A leading slash is+ bundle-relative and is the unambiguous spelling; anything else resolves+ against the concept's own directory, so a bare references/x.md on a concept+ in computations/ names computations/references/x.md.+ SEE ALSO - okf help validation How bundles are checked.- okf help okf What OKF is, end to end.+ okf help validation How bundles are checked.+ okf help computations Listing and printing attested computations.+ okf help trust Trust tiers, staleness, and recorded provenance.+ okf help index Generated index.md files, including the # Files section.+ okf help log log.md upkeep and the two staleness checks.+ okf help okf What OKF is, end to end.
+ help/graph.md view
@@ -0,0 +1,48 @@+THE CONCEPT GRAPH++Links between concepts form a graph. okf graph extracts it as JSON so another+tool can traverse a bundle without parsing Markdown.++ okf graph BUNDLE --json++ JSON is currently the only output format. The --json flag is accepted so+ later formats can be added without changing the command shape.++SHAPE++ {+ "nodes": [+ {+ "id": "tables/orders",+ "label": "Orders",+ "type": "BigQuery Table",+ "description": "Order fact table.",+ "resource": "bigquery://analytics.tables.orders",+ "tags": ["orders", "sales"]+ }+ ],+ "edges": [+ { "source": "tables/orders", "target": "tables/customers" }+ ]+ }++ One node per concept, keyed by concept ID. The label is the title where the+ concept has one. Optional metadata the concept does not carry is absent.++WHAT BECOMES AN EDGE++ A Markdown link to a .md concept that exists in the bundle. Nothing else:++ - External URLs are never edges.+ - Links to non-.md files -- a computation, an executor script under+ references/ -- are never edges.+ - A dangling link to a concept that does not exist is ignored here, though+ okf validate reports it. See "okf help validation".++ Edges are untyped. A link asserts that two concepts are related; the kind of+ relationship is conveyed by the surrounding prose, not by the graph.++SEE ALSO++ okf help format Link spellings and what a dangling reference is.+ okf help validation Referential integrity checks.
+ help/ids.md view
@@ -0,0 +1,80 @@+DOCUMENT IDS++A concept's canonical identity is its path -- tables/orders.md is the concept+tables/orders. A document ID is a second, shorter handle such as ADR-7 that a+house profile can require, for corpora where people cite decisions and requests+by number.++Document IDs are entirely a profile convention. Core OKF has no such notion, so+a bundle validated without a profile never gains or needs one.++DECLARING THEM IN A PROFILE++ A profile names the frontmatter key that holds the handle, and gives selected+ type rules the prefix their concepts must use:++ idField = Some "docId" -- profile-wide: which key holds the handle+ idPrefix = Some "ADR" -- on a TypeRule: which prefix its concepts use++ Concepts of that type must then carry a canonical handle -- PREFIX-N, with+ the prefix compared case-sensitively. Duplicate handles are reported as a+ profile deviation.++ A profile with no idField, or a type rule with no idPrefix, simply performs+ no document-ID checks.++ALLOCATING AND LISTING++ okf id list BUNDLE --profile PROFILE.dhall+ okf id next BUNDLE PREFIX --profile PROFILE.dhall++ Both subcommands require a profile, because the profile is what declares the+ ID field and the allowed prefixes. Neither writes to the bundle.++ okf id list decisions --profile profiles/decisions.dhall+ ADR-1 decisions/use-markdown+ ADR-2 decisions/use-postgres+ ADR-3 decisions/adopt-okf++ okf id next decisions ADR --profile profiles/decisions.dhall+ ADR-4++ id list prints "<handle> <concept-id>" in prefix-and-number order and omits+ malformed values. id next returns one more than the highest number for the+ requested prefix; it does not fill gaps, so a deleted ADR-2 is never reissued.++ An undeclared prefix, or a profile with no idField, is a hard error.++RESOLVING ONE++ okf show BUNDLE ADR-7+ okf show BUNDLE ADR-7 --profile PROFILE.dhall++ Path lookup runs first, because the path is the canonical OKF identity. If no+ path matches and the argument has document-ID form, show searches frontmatter+ for that exact handle and prints both identities:++ id: decisions/use-postgres+ docId: ADR-2+ type: Decision Record++ --profile narrows the search to the profile's idField. Without it, every+ string-valued frontmatter key is considered, which is why resolution works+ without a profile but is less precise. Duplicate handles are rejected as+ ambiguous and every matching concept ID is listed.++REFERENCING ONE FROM ANOTHER CONCEPT++ A profile field rule may declare that a frontmatter value holds a handle+ rather than free text, so a stale reference is caught:++ field.localReference "supersedes" "ADR"++ okf then checks that the handle exists in this bundle and belongs to a+ profile-governed concept. See "okf help profiles" for external URI schemes,+ self-reference, and the DocumentHandle field format.++SEE ALSO++ okf help profiles idField, idPrefix, and reference rules in full.+ okf help format Concept IDs, the canonical identity.
+ help/index.md view
@@ -0,0 +1,62 @@+GENERATED index.md FILES++An index.md enumerates a directory's contents so a reader or an agent can walk+a bundle top-down instead of listing files. okf generates one per directory,+deterministically, from what the directory holds.++ okf index BUNDLE Preview every generated index.+ okf index BUNDLE --write Write them into the bundle.+ okf index BUNDLE --write --okf-version 0.2 Also declare the OKF version.++ Without --write nothing is modified: each generated index prints to stdout.++WHAT A GENERATED INDEX CONTAINS++ Three sections, in this order, each omitted when it would be empty:++ # Subdirectories Immediate subdirectories, one bullet each.+ # Files Immediate non-Markdown files, one bullet each.+ <type sections> Immediate concept documents, grouped by their type+ field, with one section per type.++ The # Files section is what makes the references/ convention disclose+ anything: a directory holding only references/attesters/order-total.py has no+ concepts and no subdirectories, and would otherwise generate an empty index.++ # Files++ - [order-total.py](order-total.py)++ Names beginning with a dot are skipped, so a stray .DS_Store never reaches a+ committed index. Every .md file is left to its own typed concept section.++THE VERSION DECLARATION++ --okf-version MAJOR.MINOR declares that version in the bundle root index's+ frontmatter as okf_version, overriding any existing declaration.++ Without the flag, an existing declaration is preserved and an absent one+ stays absent: regenerating indexes never destroys a bundle's declared version+ and never invents one.++ This is the same declaration okf validate reads, and the way to satisfy a+ profile that sets requireBundleVersion.++--write REPLACES BODIES++ --write replaces the BODY of every index.md in the bundle, which is what it+ is for. Point it at a bundle whose index files someone wrote by hand and that+ prose is gone; only the version declaration is carried across.++ Because generation is deterministic and never reads the clock, committing the+ result and running++ okf index BUNDLE --write && git diff --exit-code BUNDLE++ is a complete CI drift check.++SEE ALSO++ okf help format Reserved files and the references/ convention.+ okf help log The other reserved file, and its staleness checks.+ okf help validation How a bundle is checked.
+ help/log.md view
@@ -0,0 +1,73 @@+LOG FILES AND STALENESS++A log.md is a reserved Markdown file recording dated changes for its directory+scope. It has a title, YYYY-MM-DD date headings, and bullet entries:++ # tables Update Log++ ## 2026-06-23+ * **Update**: Refreshed schema++COMMANDS++ okf log BUNDLE Print every discovered log.md.+ okf log BUNDLE --check-stale Report concepts newer than their log.+ okf log BUNDLE --since REF Ask git which changes skipped their log.+ okf log add BUNDLE [CONCEPT_ID] --kind Update -m "Refreshed schema"++ okf log BUNDLE prints each log preceded by its bundle-relative path:++ --- tables/log.md+ # tables Update Log++ ## 2026-06-23+ * **Update**: Refreshed schema++TWO STALENESS CHECKS++ --check-stale reports concepts whose generated date is newer than the newest+ entry in the nearest enclosing log.md -- the content changed and the log did+ not say so.++ --since REF uses git to report concept .md files changed since REF when their+ nearest enclosing log.md was not changed in the same diff. If git is+ unavailable, or the bundle is not in a git checkout, the git drift check is+ skipped with a message rather than failing.++ Both are advisory. okf validate runs the same date check as part of+ validation, where --log-enforce makes those advisories fail the command:++ okf validate BUNDLE --log-enforce++ This log staleness is not the stale_after deadline okf trust reports. This one+ compares dates between a concept and its log; that one reads a deadline the+ concept declares about itself.++APPENDING AN ENTRY++ okf log add BUNDLE CONCEPT_ID --kind Update -m "Refreshed schema"+ okf log add BUNDLE --kind Update -m "Reorganized the bundle"+ okf log add BUNDLE CONCEPT_ID --kind Update -m "..." --date 2026-06-23++ With a CONCEPT_ID the entry goes in that concept's directory log.md, creating+ the file if absent. Without one it goes in the root log.md. --kind is the+ leading bold label and defaults to Update; -m/--message is required; --date+ writes a specific YYYY-MM-DD heading instead of today's in UTC.++ Re-running the same command adds another bullet; it does not deduplicate.++VALIDATION++ A present log.md must use valid YYYY-MM-DD calendar headings and non-empty+ date groups. okf validate treats a malformed log.md as a hard error in both+ permissive and strict modes -- unlike staleness, which is only ever advisory.++ A bundle generated by okf profile document with --timestamp or --generated-at+ has dates but no log.md, so do not check generated documentation with+ --log-enforce.++SEE ALSO++ okf help index The other reserved file.+ okf help validation Log advisories inside okf validate.+ okf help trust stale_after deadlines, a different check.
help/okf.md view
@@ -45,23 +45,46 @@ WHAT THE okf TOOL DOES - validate Check frontmatter conformance and, as an authoring aid, that- links between concepts resolve and log.md files are structured.- index Generate progressive-disclosure index.md files per directory.- log Preview log.md files, check stale concepts, and append entries.- graph Extract a JSON node/edge graph from links between concepts.- show Print one concept's metadata and body.+ validate Check frontmatter conformance and, as an authoring aid, that+ links resolve and log.md files are well structured.+ index Generate progressive-disclosure index.md files per directory.+ log Preview log.md files, check stale concepts, append entries.+ graph Extract a JSON node/edge graph from links between concepts.+ show Print one concept's metadata and body.+ trust Report each concept's trust tier, status, and staleness.+ sources List the provenance each concept records.+ computations List the attested computations a bundle declares.+ id Allocate and list profile-declared document IDs.+ profile List and inspect profiles published by a registry.+ config Show and manage okf configuration.+ kit Install and manage agent skills and subagents.+ assist Launch an interactive agent session with installed okf skills.+ completions Generate a shell completion script. STANDALONE BY DESIGN The okf CLI works on plain files only. It does not require Mori, Mina, an LLM, PostgreSQL, or network access. It tracks Google's Open Knowledge Format- v0.1 specification (the knowledge-catalog okf SPEC.md).+ v0.2 specification (the knowledge-catalog okf SPEC.md). + It reads a v0.1 bundle unchanged. A bundle that declares nothing is read+ with v0.1 fallbacks and is never penalized for it; declaring+ okf_version: "0.2" in the root index.md opts into the stricter reading.+ SEE ALSO - okf help format Bundle layout, concept IDs, frontmatter, and links.- okf help validation What "conformant" means and how the tool checks it.- okf help profiles Checking a bundle against house conventions.+ okf help format Bundle layout, concept IDs, frontmatter, and links.+ okf help validation What "conformant" means and how the tool checks it.+ okf help profiles Checking a bundle against house conventions.+ okf help index Generated index.md files and the version declaration.+ okf help log log.md upkeep and the two staleness checks.+ okf help graph The concept graph and what becomes an edge.+ okf help trust Trust tiers, staleness, and recorded provenance.+ okf help computations Listing and printing attested computations.+ okf help ids Profile-declared document IDs such as ADR-7.+ okf help interactive Picking a bundle and concept with fzf.+ okf help config Config files, defaults, and agent settings.+ okf help kit Installing and publishing agent skills and subagents.+ okf help agents Installing agent skills and launching assist. The full user guide lives under docs/user/ in the okf repository.
help/profiles.md view
@@ -63,6 +63,67 @@ profile rather than a record of profiles; the ID FIELD column reads "-" when the profile declares no idField. +GENERATING DOCUMENTATION++ A profile can generate an OKF bundle documenting itself: one page for the+ profile, one page per concept type it declares.++ okf profile document --profile PROFILE.dhall+ okf profile document --profile PROFILE.dhall --out DIR --write++ Without --write the command prints what it would generate and touches+ nothing. With --out DIR --write it writes the pages and the index.md files+ and prints a one-line summary. --write without --out is an error, and so is+ combining --profile with an EXPORT argument or --registry.++ Without --profile the profile comes from a registry export, using the same+ --registry precedence as list and show.++ Each type page shows the EFFECTIVE rules for that type: the profile-wide+ rules and the type's own, already merged. That is the difference from+ `okf profile show`, which shows the two declaration sites separately and+ leaves you to compose them. Recommended keys carry a bullet saying they are+ checked only under --strict.++ The `description` prose you write on the profile, on a type rule, and on+ each key is what fills the generated pages. A profile with no descriptions+ still generates, with synthesized summaries.++ The output is an ordinary OKF bundle, so okf validate, okf graph, and+ okf show all work on it. Generation never reads the clock, so regenerating+ produces the same bytes; committing the result and running++ git diff --exit-code DIR++ after regenerating is a complete CI drift check.++ Every generated page records who produced it:++ generated:+ by: process:okf-profile-document++ so default output passes `okf validate --strict` with no extra flag. Pass+ --generated-by ACTOR to name a different producer -- <producer>/<version>,+ human:<id>, or process:<id> -- and --generated-at RFC3339 to record when.+ Neither is written unless you ask: generation never reads the clock.++ Pass --okf-version 0.2 to declare the OKF version in the generated bundle's+ root index, exactly as `okf index --okf-version` does. Omitting it preserves+ whatever declaration the destination already carries.++ Two things that will otherwise look like bugs:++ A bundle generated with --timestamp or --generated-at has dates but no+ log.md, so do not check generated documentation with --log-enforce.++ --write regenerates index.md for EVERY directory under --out, including+ ones it did not write into, and never deletes a file it did not generate.+ Point --out at a directory dedicated to the generated documentation.++ okf ships docs/profiles/profile-documentation.dhall, a profile describing+ what a generated documentation bundle looks like, and+ examples/postgresql-profile/ as a committed worked example.+ DESCRIPTIONS A profile may document itself: one description for the profile as a whole,@@ -119,6 +180,38 @@ profile: requests/typo: missing profile-required field: status profile: requests/typo: frontmatter field not declared by profile: stauts +REQUIRING A BUNDLE VERSION++ A bundle may declare which OKF version it targets, with okf_version: "0.2"+ in the frontmatter of its root index.md. Specification 12 makes that a MAY,+ so okf validate never reports a bundle for omitting it, even with --strict.++ A profile can demand it as a house convention:++ , requireBundleVersion = Some "0.2"++ A bundle that declares nothing, declares an older version, or declares+ something okf cannot parse is then a deviation. A higher version is not:++ profile: bundle does not declare okf_version; this profile requires 0.2 or later+ profile: bundle declares okf_version 0.1; this profile requires 0.2 or later++ Advisory like every other profile deviation, and fatal with+ --profile-enforce. This is the one violation that names no concept, because+ the declaration belongs to the bundle. Fix a failing bundle with++ okf index BUNDLE --write --okf-version 0.2++ requireBundleVersion is distinct from okfVersion. okfVersion says which+ version's rules the profile itself writes; requireBundleVersion says what+ the profile demands of a bundle. okf never infers one from the other. A+ value that is not <major>.<minor> is rejected when the profile compiles,+ before any bundle is read.++ docs/profiles/postgresql.dhall sets it. docs/profiles/okf-v0-2.dhall+ deliberately does not: a format-level profile that demanded what the+ specification only permits would advise against the specification.+ FIELD CARDINALITY Every FieldRule has a cardinality: Any, Scalar, or List. Any is the default@@ -299,6 +392,9 @@ SEE ALSO okf help validation Structural validation and referential integrity.+ okf help ids Document IDs: idField, idPrefix, and okf id.+ okf help index Declaring okf_version to satisfy requireBundleVersion. The full descriptor schema, and the upgrade steps above in detail, are- documented in docs/user/profiles.md.+ documented in docs/user/profiles.md, whose "Generating profile+ documentation" section covers `okf profile document` at length.
+ help/trust.md view
@@ -0,0 +1,89 @@+TRUST, STALENESS, AND PROVENANCE++Two commands report what a bundle says about how far its content can be+trusted. Neither stores anything: every value is derived from frontmatter on+each run, and nothing is read that the bundle does not say.++REPORTING TRUST TIERS++ okf trust BUNDLE++ One aligned row per concept, ordered by concept ID:++ aggregates/invoice human-reviewed stable ok+ aggregates/order machine-confirmed stable ok+ commands/issue-invoice unverified stable ok+ mappings/ordering-to-billing machine-confirmed stable stale since 2026-07-01+ policies/reserve-stock unverified draft ok++ The four columns are the concept ID, the derived trust tier, the status+ field, and staleness. The ID column is padded to the longest ID in the+ bundle, so the width shifts when a bundle gains a deeper concept.++TIERS++ unverified The concept declares no verified entry.+ machine-confirmed Verified, but every verified[].by is a non-human actor.+ human-reviewed Some verified[].by uses the human: prefix.++ The prefix is the only thing separating the last two. The tier comes from+ verified, never from generated: who wrote a concept need not be who confirmed+ it.++STATUS AND STALENESS++ status shows stable for a concept that declares none, because an absent+ status means stable. The other values are draft, deprecated, and superseded.++ The staleness column reads "ok" both for a concept with no stale_after and+ for one whose deadline has not arrived -- okf does not claim a concept is+ fresh, only that nothing says otherwise. A passed deadline prints+ "stale since DATE", and a stale_after that is not a YYYY-MM-DD date prints+ "unparseable stale_after VALUE" rather than being silently treated as fresh.++ Staleness is computed against today, so this command's output changes with+ the date even when the bundle does not. It is advisory: okf never refuses a+ stale concept.++ Staleness here is not the log staleness okf validate and okf log+ --check-stale report. That one compares a concept's generated date against+ the nearest log.md; this one reads the concept's own stale_after deadline.++LISTING RECORDED PROVENANCE++ okf sources BUNDLE++ The provenance each concept records, with the credibility signals that frame+ it:++ aggregates/order+ ddd-schema mori://shinzui/mori+ author human:nadeem, used 40 times in 2026-01-01..2026-06-18, modified 2026-05-02+ ubiquitous-language all order-domain terms agreed in the ordering team's glossary reviews+ author human:nadeem, used 6 times in 2026-03-01..2026-06-10, modified 2026-06-10++ Concepts with no sources are skipped entirely, so the report shows only what+ has provenance. Concepts are ordered by ID, so the output is stable and+ diffable in a pipeline.++ Entries print in the order the document declares them and are never sorted or+ ranked by usage_count. A count is a coarse signal -- read it as liveness and+ trend, not as a score -- and a ranked listing would imply a precision it does+ not carry.++ The window shown after a count is the EFFECTIVE one: an entry's own+ usage_window where it has one, the document-scope usage_window otherwise,+ which is why two entries on one concept can show different ranges.++ A second line is printed only for the signals an entry actually has. An entry+ with no id prints "(no id)" in the label column, because an id is optional and+ matters only when the body cites the entry with a footnote.++ A source's resource is deliberately never path-checked: it names either a+ followable artifact or a scope descriptor such as "all queries in project X".++SEE ALSO++ okf help format The generated, verified, status, and sources families.+ okf help validation Which provenance problems --strict reports.+ okf help log Log staleness, which is a different check.
help/validation.md view
@@ -9,14 +9,34 @@ PERMISSIVE VS STRICT Default (permissive) validation requires each concept to have a non-empty- type frontmatter field.+ type frontmatter field, and checks that values it can read have the right+ shape -- that tags is a list of strings, for instance. --strict additionally requires the recommended authoring fields: title description- timestamp+ generated (or the superseded v0.1 timestamp it falls back to) + and reports authoring problems in the optional OKF v0.2 families a document+ has actually used:++ generated must carry a by actor+ a sources entry is missing its resource+ two sources entries share an id+ a body footnote label names no sources entry, or the reverse+ a superseded v0.1 field in a bundle that declares okf_version 0.2+ a path in a frontmatter value names a file the bundle does not hold++ and, for a concept whose type is exactly "Attested Computation":++ it declares no runtime+ it declares no computation at all+ it declares a computation both inline and by path+ it has more than one code block under # Computation++ Every one of those is strict-only. See CONFORMANCE VS AUTHORING CHECKS.+ REFERENTIAL INTEGRITY Validation also checks the whole bundle, not just single files:@@ -30,9 +50,21 @@ These checks run in both permissive and strict modes. + A path held in a frontmatter value -- resource, computation,+ executor.resource, attester.resource -- is resolved too, but only under+ --strict, and only the top-level resource plus the three attested+ computation fields. A sources entry's resource is deliberately not+ path-checked: it names either a followable artifact or a scope descriptor+ such as "all queries in project X", so treating it as a path would report+ correct bundles as broken.++ A relative path resolves against the concept's own directory. A path that+ fails to resolve but would have resolved from the bundle root says so, and+ names the leading-slash spelling to write instead.+ LOG ADVISORIES - okf validate reports concepts whose timestamp date is newer than the newest+ okf validate reports concepts whose generated date is newer than the newest entry in the nearest enclosing log.md. These stale-log findings are advisory by default: @@ -40,32 +72,56 @@ okf validate BUNDLE --log-enforce Use --log-enforce to make stale-log advisories fail the command. Use- okf log BUNDLE --check-stale for the same timestamp check without running all+ okf log BUNDLE --check-stale for the same date check without running all validation, or okf log BUNDLE --since REF to ask git which changed concept files did not change their nearest log.md in the same diff. CONFORMANCE VS AUTHORING CHECKS - OKF v0.1 conformance itself is permissive: it requires only parseable- frontmatter with a non-empty type field, and it tells consumers to TOLERATE- broken links (a link to a not-yet-written concept is not malformed). okf- validate is an authoring-time linter, so it deliberately goes further and- flags dangling references and duplicate IDs to catch mistakes before you- publish. Treat these as authoring aids, not as a gate on what consumers- will accept.+ OKF conformance itself is permissive. It requires only parseable frontmatter+ with a non-empty type field, and it tells consumers to TOLERATE broken links+ (a link to a not-yet-written concept is not malformed) and NOT to reject a+ bundle for a missing optional field or an unrecognized type value. + That is why every check on an optional v0.2 family lives under --strict,+ including the ones the specification marks REQUIRED "for this type". Marking+ runtime REQUIRED for an Attested Computation binds the producer; it does not+ license a consumer to refuse the bundle. okf validate is an authoring-time+ linter, so it goes further than conformance on purpose -- treat these as+ authoring aids, not as a gate on what consumers will accept.++ A team that wants any of them enforced writes a house profile, which okf+ can then check and, with --profile-enforce, fail on. See+ "okf help profiles".++VERSION DECLARATION++ A bundle may declare its format version in its root index.md frontmatter:++ okf_version: "0.2"++ An undeclared bundle is read with v0.1 fallbacks and is never penalized for+ it. Declaring 0.2 is an opt-in to the stricter reading: after that, a+ superseded v0.1 field such as timestamp is reported under --strict, because+ a bundle that says it targets v0.2 is describing an authoring mistake rather+ than exercising a compatibility path.+ OUTPUT A valid bundle prints a concept count and exits 0: - OK: 4 concepts+ OK: 4 concepts (okf_version 0.2) An invalid bundle prints deterministic errors to stderr and exits non-zero, for example: orders: link to missing concept: customers+ computations/revenue: Attested Computation concepts must declare runtime SEE ALSO - okf help profiles Checking a bundle against house conventions.- okf help format Concept IDs, frontmatter, and links.+ okf help profiles Checking a bundle against house conventions.+ okf help format Concept IDs, frontmatter, and links.+ okf help computations The attested computation checks, in context.+ okf help log Log advisories and --log-enforce.+ okf help index Declaring okf_version with okf index.
okf-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: okf-cli-version: 0.4.0.0+version: 0.5.0.0 synopsis: Command-line interface for Open Knowledge Format bundles description: okf-cli provides the @okf@ executable for working with Open Knowledge Format@@ -66,7 +66,7 @@ , generic-lens >=2.2 && <2.4 , githash ^>=0.1 , lens ^>=5.3- , okf-core ^>=0.4.0.0+ , okf-core ^>=0.5.0.0 , optparse-applicative >=0.18 && <0.20 , process >=1.6 && <1.7 , text ^>=2.1@@ -82,7 +82,7 @@ , directory , filepath , okf-cli- , okf-core ^>=0.4.0.0+ , okf-core ^>=0.5.0.0 , optparse-applicative >=0.18 , temporary , text ^>=2.1
src/Okf/Cli.hs view
@@ -1,6 +1,7 @@ -- | Top-level CLI entry point for okf. module Okf.Cli ( Command (..),+ ComputationsOptions (..), GraphOptions (..), IdOptions (..), IdSub (..),@@ -11,10 +12,12 @@ LogSub (..), Options (..), ProfileCommand (..),+ ProfileDocumentOptions (..), ProfileListOptions (..), ProfileShowOptions (..), ShowOptions (..), ValidateOptions (..),+ computationReport, parserInfo, profileRegistryEnvVar, renderProfileDetail,@@ -32,10 +35,13 @@ import Data.ByteString.Lazy.Char8 qualified as LazyByteString import Data.Foldable (toList, traverse_) import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Data.Time (defaultTimeLocale, formatTime, getCurrentTime, utctDay)+import Okf.Actor (parseActor, renderActor) import Okf.Bundle import Okf.Cli.Assist (AssistOptions, assistOptionsParser, handleAssistCommand) import Okf.Cli.Completions (CompletionsShell, completionsParser, handleCompletions)@@ -52,11 +58,29 @@ import Okf.Cli.Kit (KitCommand, handleKitCommand, kitCommandParser) import Okf.Cli.Version (appVersionWithGit) import Okf.ConceptId-import Okf.Document (DocumentParseError (..), Frontmatter (..), OKFDocument (..), body)+import Okf.Document+ ( Attester (..),+ ComputationSource (..),+ DocumentParseError (..),+ Executor (..),+ Frontmatter (..),+ Generated (..),+ OKFDocument (..),+ Parameter (..),+ Source (..),+ UsageWindow (..),+ attestedComputationType,+ body,+ effectiveUsageWindow,+ renderStatus,+ ) import Okf.Graph (buildGraph) import Okf.Index import Okf.Log qualified as Log-import Okf.Prelude hiding (List)+import Okf.Path (PathReference (..), classifyPathReference)+-- 'List' and 'Object' are 'Okf.Profile.Cardinality' constructors here; aeson's+-- same-named 'Value' constructors are reached as 'Aeson.Object' and friends.+import Okf.Prelude hiding (List, Object) import Okf.Profile ( Cardinality (..), CompiledProfile,@@ -69,19 +93,32 @@ -- @optparse-applicative@'s, so this module reads all three presence lists -- through generic-lens labels instead. HandleReferenceRule (..),+ PathReferenceRule (..), ProfileDefinitionError (..), ProfileSpec (..), ProfileViolation (..), TypeRule (..), compileProfile,+ compiledProfileRulesForType, documentIdsInBundle,+ fieldRuleDescription,+ fieldRuleElementFields,+ fieldRuleObjectFields, loadProfileFile, nextDocumentId, parseDocumentId, profileFieldDescriptionForType, renderDocumentId,- validateProfile,+ validateProfileVersion,+ validateProfileWith, )+import Okf.Profile.Documentation+ ( DocumentationError (..),+ DocumentationOptions (..),+ defaultDocumentationActor,+ defaultDocumentationOptions,+ renderProfileDocumentation,+ ) import Okf.Profile.Registry ( RegistryEntry (..), RegistryRef (..),@@ -91,6 +128,14 @@ resolveRegistryRef, rootExportLabel, )+import Okf.Trust+ ( Staleness (..),+ latestVerification,+ renderStaleness,+ renderTrustTier,+ staleness,+ trustTier,+ ) import Okf.Validation import Options.Applicative import System.Directory (createDirectoryIfMissing, doesFileExist)@@ -107,6 +152,9 @@ | Log LogOptions | GraphCommand GraphOptions | ShowConcept ShowOptions+ | Trust TrustOptions+ | Sources SourcesOptions+ | Computations ComputationsOptions | Id IdOptions | Config ConfigCommand | Profile ProfileCommand@@ -127,7 +175,8 @@ data IndexOptions = IndexOptions { bundlePath :: !FilePath,- write :: !Bool+ write :: !Bool,+ okfVersion :: !(Maybe Text) } deriving stock (Show, Eq) @@ -161,10 +210,26 @@ data ShowOptions = ShowOptions { bundlePath :: !(Maybe FilePath), conceptIdText :: !(Maybe Text),- profilePath :: !(Maybe FilePath)+ profilePath :: !(Maybe FilePath),+ computationOnly :: !Bool } deriving stock (Show, Eq) +data TrustOptions = TrustOptions+ { bundlePath :: !FilePath+ }+ deriving stock (Show, Eq)++data SourcesOptions = SourcesOptions+ { bundlePath :: !FilePath+ }+ deriving stock (Show, Eq)++data ComputationsOptions = ComputationsOptions+ { bundlePath :: !FilePath+ }+ deriving stock (Show, Eq)+ data IdOptions = IdOptions { bundlePath :: !FilePath, profilePath :: !FilePath,@@ -186,6 +251,7 @@ data ProfileCommand = ProfileList ProfileListOptions | ProfileShow ProfileShowOptions+ | ProfileDocument ProfileDocumentOptions deriving stock (Show, Eq) data ProfileListOptions = ProfileListOptions@@ -201,6 +267,19 @@ } deriving stock (Show, Eq) +data ProfileDocumentOptions = ProfileDocumentOptions+ { registryRef :: !(Maybe Text),+ export :: !(Maybe Text),+ profilePath :: !(Maybe FilePath),+ outputPath :: !(Maybe FilePath),+ write :: !Bool,+ timestamp :: !(Maybe Text),+ generatedBy :: !(Maybe Text),+ generatedAt :: !(Maybe Text),+ okfVersion :: !(Maybe Text)+ }+ deriving stock (Show, Eq)+ data Options = Options { cmd :: !Command }@@ -237,6 +316,9 @@ <> command "log" (info (Log <$> logOptionsParser <**> helper) (progDesc "Preview and check log.md files")) <> command "graph" (info (GraphCommand <$> graphOptionsParser <**> helper) (progDesc "Print a bundle graph")) <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))+ <> command "trust" (info (Trust <$> trustOptionsParser <**> helper) (progDesc "Report trust tiers, status, and staleness for every concept"))+ <> command "sources" (info (Sources <$> sourcesOptionsParser <**> helper) (progDesc "List the provenance recorded by each concept"))+ <> command "computations" (info (Computations <$> computationsOptionsParser <**> helper) (progDesc "List the attested computations a bundle declares")) <> command "id" (info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs")) <> command "config" (info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration")) <> command "profile" (info (Profile <$> profileCommandParser <**> helper) (progDesc "List and inspect profiles published by a registry"))@@ -266,6 +348,13 @@ IndexOptions <$> bundleArgument <*> switch (long "write" <> help "Write generated index.md files instead of previewing")+ <*> optional+ ( strOption+ ( long "okf-version"+ <> metavar "MAJOR.MINOR"+ <> help "Declare the OKF version in the bundle root index"+ )+ ) logOptionsParser :: Parser LogOptions logOptionsParser =@@ -368,6 +457,10 @@ <> help "Narrow document ID lookup to a profile's idField" ) )+ <*> switch+ ( long "computation"+ <> help "Print only the computation and nothing else"+ ) idOptionsParser :: Parser IdOptions idOptionsParser =@@ -427,6 +520,12 @@ (ProfileShow <$> profileShowOptionsParser <**> helper) (progDesc "Print one registry profile in full") )+ <> command+ "document"+ ( info+ (ProfileDocument <$> profileDocumentOptionsParser <**> helper)+ (progDesc "Generate an OKF bundle documenting a profile")+ ) ) <|> pure (ProfileList (ProfileListOptions Nothing False)) @@ -449,6 +548,67 @@ ) <*> jsonSwitch +profileDocumentOptionsParser :: Parser ProfileDocumentOptions+profileDocumentOptionsParser =+ ProfileDocumentOptions+ <$> optional registryOption+ <*> optional+ ( Text.pack+ <$> strArgument+ ( metavar "EXPORT"+ <> help "Dotted export path of the profile, as printed by `okf profile list`"+ )+ )+ <*> optional+ ( strOption+ ( long "profile"+ <> metavar "PROFILE"+ <> help "Document a Dhall descriptor file directly instead of a registry export"+ )+ )+ <*> optional+ ( strOption+ ( long "out"+ <> metavar "DIR"+ <> help "Directory to write the generated bundle into"+ )+ )+ <*> switch+ ( long "write"+ <> help "Write the bundle to --out instead of previewing it on standard output"+ )+ <*> optional+ ( Text.pack+ <$> strOption+ ( long "timestamp"+ <> metavar "RFC3339"+ <> help "Value for the superseded v0.1 timestamp frontmatter key; omitted entirely when not given. Provenance is written as generated.by by default, so this is needed only when producing a v0.1 bundle."+ )+ )+ <*> optional+ ( Text.pack+ <$> strOption+ ( long "generated-by"+ <> metavar "ACTOR"+ <> help "Actor recorded in generated.by on every page: <producer>/<version>, human:<id>, or process:<id>. Defaults to process:okf-profile-document."+ )+ )+ <*> optional+ ( Text.pack+ <$> strOption+ ( long "generated-at"+ <> metavar "RFC3339"+ <> help "Timestamp recorded in generated.at. Omitted when not given, because generation never reads the clock."+ )+ )+ <*> optional+ ( strOption+ ( long "okf-version"+ <> metavar "MAJOR.MINOR"+ <> help "Declare the OKF version in the generated bundle's root index"+ )+ )+ registryOption :: Parser Text registryOption = Text.pack@@ -465,6 +625,15 @@ bundleArgument = strArgument (metavar "BUNDLE" <> help "Path to an OKF bundle directory") +trustOptionsParser :: Parser TrustOptions+trustOptionsParser = TrustOptions <$> bundleArgument++sourcesOptionsParser :: Parser SourcesOptions+sourcesOptionsParser = SourcesOptions <$> bundleArgument++computationsOptionsParser :: Parser ComputationsOptions+computationsOptionsParser = ComputationsOptions <$> bundleArgument+ runCommand :: Command -> IO () runCommand = \case Validate options -> runValidate options@@ -472,6 +641,9 @@ Log options -> runLog options GraphCommand options -> runGraph options ShowConcept options -> runShow options+ Trust options -> runTrust options+ Sources options -> runSources options+ Computations options -> runComputations options Id options -> runId options Config configCommand -> runConfig configCommand Profile profileCommand -> runProfile profileCommand@@ -521,6 +693,7 @@ runProfile = \case ProfileList options -> runProfileList options ProfileShow options -> runProfileShow options+ ProfileDocument options -> runProfileDocument options -- | Registry reference precedence: @--registry@, then 'profileRegistryEnvVar', -- then configuration (which falls back to the built-in default). Configuration@@ -661,6 +834,171 @@ "Available exports: " <> Text.intercalate ", " [displayExport exportPath | RegistryEntry {export = exportPath} <- found] +-- | Generate an OKF bundle documenting a profile.+--+-- Preview by default: nothing is written unless both @--out DIR@ and @--write@+-- are given. The write rules are recorded in+-- @docs\/adr\/6-generated-profile-documentation.md@: the command overwrites+-- exactly the files it generates, never deletes, and reports concepts already+-- in the destination that this run did not generate.+runProfileDocument :: ProfileDocumentOptions -> IO ()+runProfileDocument+ ProfileDocumentOptions+ { registryRef,+ export = requestedExport,+ profilePath,+ outputPath,+ write,+ timestamp,+ generatedBy,+ generatedAt,+ okfVersion+ } = do+ when (isJust profilePath && (isJust requestedExport || isJust registryRef)) $+ dieText "Pass either --profile PATH or an EXPORT argument, not both."+ when (write && isNothing outputPath) $+ dieText "--write needs a destination; pass --out DIR."+ -- Parse the requested version before anything is written, so a malformed+ -- one fails without leaving a half-generated bundle behind.+ versionOverride <- traverse requestedVersion okfVersion+ compiled <- resolveCompiledProfile+ concepts <- case renderProfileDocumentation documentationOptions compiled of+ Left documentationError -> dieText (renderDocumentationError documentationError)+ Right rendered -> pure rendered+ case (write, outputPath) of+ (True, Just destination) -> writeProfileDocumentation versionOverride destination concepts+ _ -> previewProfileDocumentation outputPath concepts+ where+ -- Same diagnostic as @okf index --okf-version@, so the two commands+ -- reject a malformed version identically.+ requestedVersion rawVersion =+ case parseOkfVersion rawVersion of+ Just version -> pure version+ Nothing -> dieText ("Not an OKF version of the form MAJOR.MINOR: " <> rawVersion)++ documentationOptions =+ DocumentationOptions+ { rootConceptId = rootConceptId defaultDocumentationOptions,+ typeDirectory = typeDirectory defaultDocumentationOptions,+ timestamp = timestamp,+ -- 'parseActor' is total, so @--generated-by Nadeem@ is preserved+ -- verbatim as an unclassified actor rather than rejected:+ -- specification §11 forbids failing on a malformed optional field.+ generated =+ Just+ ( Generated+ (maybe defaultDocumentationActor parseActor generatedBy)+ generatedAt+ )+ }++ resolveCompiledProfile = do+ (label, spec) <- case profilePath of+ Just path -> do+ loadedSpec <- loadProfileOrExit path+ pure (Text.pack path, loadedSpec)+ Nothing -> do+ (reference, _ref, entries) <- loadRegistryOrDie registryRef+ RegistryEntry {export = foundExport, spec} <-+ selectEntry reference entries requestedExport+ pure (displayExport foundExport, spec)+ case compileProfile spec of+ Left definitionErrors ->+ dieText+ ( "Failed to load profile "+ <> label+ <> ": invalid profile definition:\n"+ <> Text.intercalate+ "\n"+ (map ((" - " <>) . renderProfileDefinitionError) (toList definitionErrors))+ )+ Right compiled -> pure compiled++-- | Print every file the command would generate, in the same shape+-- @okf index@ previews its own output, then say what would happen on+-- @--write@. Touches nothing.+previewProfileDocumentation :: Maybe FilePath -> [Concept] -> IO ()+previewProfileDocumentation destination concepts = do+ mapM_+ (\concept -> renderIndexPreview (conceptSourcePath concept, serializeConcept concept))+ concepts+ Text.IO.putStrLn summary+ where+ summary =+ case destination of+ Just directory ->+ "(preview only; pass --write to write these "+ <> countPhrase (length concepts) "file"+ <> " to "+ <> Text.pack directory+ <> ")"+ Nothing ->+ "(preview only; pass --out DIR --write to write these "+ <> countPhrase (length concepts) "file"+ <> ")"++-- | Write the generated bundle and regenerate the destination's index files.+--+-- Overwrites exactly the concepts it generated and never deletes, so a+-- destination holding pages from an earlier profile keeps them. Those are+-- reported rather than silently left to rot.+--+-- A 'Just' version override declares that OKF version in the destination's root+-- index; 'Nothing' preserves whatever declaration is already there, so+-- regenerating into a directory that declares a version cannot strip it.+writeProfileDocumentation :: Maybe OkfVersion -> FilePath -> [Concept] -> IO ()+writeProfileDocumentation versionOverride destination concepts = do+ -- A destination that does not exist yet walks as an IO error, which here+ -- means "no pre-existing concepts" rather than a failure: writeBundle+ -- creates the directory.+ existing <- walkBundle destination+ let generated = Set.fromList (map conceptIdOf concepts)+ stale =+ [ conceptId+ | concept <- either (const []) Prelude.id existing,+ let conceptId = conceptIdOf concept,+ not (Set.member conceptId generated)+ ]+ writeBundle destination concepts+ indexes <- renderBundleIndexesWith versionOverride destination+ indexCount <- case indexes of+ Left bundleError -> dieText (renderBundleError bundleError)+ -- Count distinct paths: 'renderBundleIndexes' yields the bundle root twice,+ -- once as "" and once as ".", which write the same file.+ Right rendered -> pure (length (List.nub (map (FilePath.normalise . fst) rendered)))+ written <- writeBundleIndexesWith versionOverride destination+ case written of+ Left bundleError -> dieText (renderBundleError bundleError)+ Right () -> pure ()+ Text.IO.putStrLn+ ( "Wrote "+ <> countPhrase (length concepts) "concept"+ <> " and "+ <> countPhrase indexCount "index.md file"+ <> " to "+ <> Text.pack destination+ )+ unless (null stale) $+ Text.IO.putStrLn+ ( "Note: "+ <> Text.pack destination+ <> " also contains "+ <> countPhrase (length stale) "concept"+ <> " this profile did not generate ("+ <> Text.intercalate ", " (map renderConceptId stale)+ <> "). Left untouched; delete them if their type rules were removed."+ )++-- | @1 concept@ but @2 concepts@, so summary lines read as English.+countPhrase :: Int -> Text -> Text+countPhrase count noun =+ Text.pack (show count) <> " " <> noun <> (if count == 1 then "" else "s")++renderDocumentationError :: DocumentationError -> Text+renderDocumentationError = \case+ InvalidRootConceptId raw err -> renderConceptIdError raw err+ InvalidTypeDirectory raw err -> renderConceptIdError raw err+ -- | One profile's complete rule set. Every optional field prints as @(none)@ -- rather than being omitted, so the output shape does not change between -- profiles and stays reliable to eyeball or grep. Type rules print in the order@@ -676,12 +1014,14 @@ allowUnknownTypes, allowUnknownFields, idField,+ requireBundleVersion, types = typeRules } = [ "export: " <> displayExport exportPath, "name: " <> name, "description: " <> renderOptional description, "okfVersion: " <> okfVersion,+ "requireBundleVersion: " <> renderOptional requireBundleVersion, "allowUnknownTypes: " <> renderFlag allowUnknownTypes, "allowUnknownFields: " <> renderFlag allowUnknownFields, "idField: " <> renderOptional idField@@ -711,16 +1051,27 @@ indent <> " cardinality: " <> renderCardinality (rule ^. #cardinality), indent <> " format: " <> maybe "(none)" renderFieldFormat (rule ^. #format), indent <> " reference: " <> maybe "(none)" renderHandleReferenceRule (rule ^. #reference),+ indent <> " path: " <> maybe "(none)" renderPathReferenceRule (rule ^. #path), indent <> " when: " <> maybe "(none)" renderCondition (rule ^. #when) ]- <> case rule ^. #elementFields of- Nothing -> [indent <> " elementFields: (none)"]- Just nestedRules ->- [indent <> " elementFields:"]- <> renderNestedFieldRules (indent <> " ") "required" (nestedRules ^. #required)- <> renderNestedFieldRules (indent <> " ") "recommended" (nestedRules ^. #recommended)- <> renderNestedFieldRules (indent <> " ") "optional" (nestedRules ^. #optional)+ -- The two nested shapes print together and in this order, matching+ -- 'Okf.Profile.Documentation.renderFieldRule', which is the other+ -- renderer of the same rule. @objectFields@ constrains the record that+ -- /is/ the value, so it reaches @executor.resource@; @elementFields@+ -- constrains the members of each element of a list, so it reaches+ -- @sources[].resource@. A reader comparing the two should not have to+ -- scroll past seven scalar lines to find the second one.+ <> renderNestedBlock indent "objectFields" (rule ^. #objectFields)+ <> renderNestedBlock indent "elementFields" (rule ^. #elementFields) + renderNestedBlock indent label = \case+ Nothing -> [indent <> " " <> label <> ": (none)"]+ Just nestedRules ->+ [indent <> " " <> label <> ":"]+ <> renderNestedFieldRules (indent <> " ") "required" (nestedRules ^. #required)+ <> renderNestedFieldRules (indent <> " ") "recommended" (nestedRules ^. #recommended)+ <> renderNestedFieldRules (indent <> " ") "optional" (nestedRules ^. #optional)+ renderNestedFieldRules indent label [] = [indent <> label <> ": " <> renderList []] renderNestedFieldRules indent label rules = (indent <> label <> ":") : concatMap (renderNestedFieldRule indent) rules@@ -730,6 +1081,7 @@ indent <> " allowedValues: " <> renderVocabulary (rule ^. #allowedValues), indent <> " cardinality: " <> renderCardinality (rule ^. #cardinality), indent <> " format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),+ indent <> " path: " <> maybe "(none)" renderPathReferenceRule (rule ^. #path), indent <> " when: " <> maybe "(none)" renderCondition (rule ^. #when) ] @@ -793,13 +1145,15 @@ runValidate :: ValidateOptions -> IO () runValidate ValidateOptions {bundlePath, strictMode, profilePath, profileEnforce, logEnforce} = do concepts <- loadBundleOrExit bundlePath+ inventory <- loadBundleInventoryOrExit bundlePath logs <- loadLogsOrExit bundlePath+ declaration <- loadBundleVersionOrExit bundlePath let coreProfile = if strictMode then StrictAuthoring else PermissiveConformance- coreErrors = validateBundle coreProfile concepts <> validateBundleLogs logs+ coreErrors = validateBundle coreProfile declaration inventory concepts <> validateBundleLogs logs mapM_ (Text.IO.hPutStrLn stderr . renderBundleValidationError) coreErrors - let staleness = logStaleness concepts logs- mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) staleness+ let logStalenessReport = logStaleness concepts logs+ mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) logStalenessReport profileViolations <- case profilePath of Nothing -> pure []@@ -817,41 +1171,60 @@ <> Text.intercalate "\n" (map ((" - " <>) . renderProfileDefinitionError) (toList definitionErrors)) ) Right compiled -> do- let violations = validateProfile coreProfile compiled concepts+ -- With the inventory rather than without it, so a @path@ rule+ -- naming §6.3's @references/attesters/revenue.py@ is resolved+ -- rather than accepted unchecked. This command walked a real+ -- directory, so it can answer the question.+ -- The version requirement first: a bundle that does not declare+ -- what the profile demands is context for every line below it.+ let violations =+ validateProfileVersion declaration compiled+ <> validateProfileWith inventory coreProfile compiled concepts mapM_ (Text.IO.hPutStrLn stderr . ("profile: " <>) . renderProfileViolation compiled concepts) violations pure violations let coreFailed = any bundleValidationErrorIsFailure coreErrors profileFailed = profileEnforce && not (null profileViolations)- logFailed = logEnforce && (any bundleValidationErrorIsAdvisory coreErrors || not (null staleness))+ logFailed = logEnforce && (any bundleValidationErrorIsAdvisory coreErrors || not (null logStalenessReport)) if coreFailed || profileFailed || logFailed then exitFailure else do- Text.IO.putStrLn ("OK: " <> Text.pack (show (length concepts)) <> " concepts")+ Text.IO.putStrLn+ ( "OK: "+ <> Text.pack (show (length concepts))+ <> " concepts"+ <> renderDeclaredVersion declaration+ ) unless (null profileViolations) $ Text.IO.putStrLn ( "profile: " <> Text.pack (show (length profileViolations)) <> " advisory deviation(s) (use --profile-enforce to fail)" )- unless (null staleness) $+ unless (null logStalenessReport) $ Text.IO.putStrLn ( "log: "- <> Text.pack (show (length staleness))+ <> Text.pack (show (length logStalenessReport)) <> " stale concept advisory/advisories (use --log-enforce to fail)" ) runIndex :: IndexOptions -> IO ()-runIndex IndexOptions {bundlePath, write} =+runIndex IndexOptions {bundlePath, write, okfVersion} = do+ override <- traverse requestedVersion okfVersion if write then do- result <- writeBundleIndexes bundlePath+ result <- writeBundleIndexesWith override bundlePath case result of Left bundleError -> dieText (renderBundleError bundleError) Right () -> Text.IO.putStrLn "Wrote index.md files" else do- indexes <- loadIndexesOrExit bundlePath+ indexes <- loadIndexesOrExit override bundlePath mapM_ renderIndexPreview indexes+ where+ requestedVersion rawVersion =+ case parseOkfVersion rawVersion of+ Just version -> pure version+ Nothing -> dieText ("Not an OKF version of the form MAJOR.MINOR: " <> rawVersion) runLog :: LogOptions -> IO () runLog LogOptions {bundlePath, checkStale, sinceRef, logSub = LogPreview} = do@@ -862,13 +1235,13 @@ case sinceRef of Nothing -> pure () Just ref -> runGitDriftCheck bundlePath ref logs- staleness <-+ logStalenessReport <- if checkStale then do concepts <- loadBundleOrExit bundlePath pure (logStaleness concepts logs) else pure []- mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) staleness+ mapM_ (Text.IO.hPutStrLn stderr . ("log: " <>) . renderLogStaleness) logStalenessReport when (any bundleValidationErrorIsFailure logErrors) exitFailure runLog LogOptions {bundlePath, logSub = LogAdd addOptions} = runLogAdd bundlePath addOptions@@ -986,22 +1359,214 @@ LazyByteString.putStrLn (Aeson.encode (buildGraph concepts)) runShow :: ShowOptions -> IO ()-runShow ShowOptions {bundlePath, conceptIdText, profilePath} = do+runShow ShowOptions {bundlePath, conceptIdText, profilePath, computationOnly} = do fzfConfig <- detectFzfConfig resolvedBundle <- resolveBundlePath fzfConfig bundlePath concepts <- loadBundleOrExit resolvedBundle+ -- Which renderer runs is decided once, here, so that @--computation@ behaves+ -- identically whether the concept was named on the command line or picked+ -- interactively.+ let render = if computationOnly then renderComputation resolvedBundle else renderConcept case conceptIdText of- Just rawIdentifier -> showConceptByIdentifier profilePath concepts rawIdentifier+ Just rawIdentifier -> showConceptByIdentifier render profilePath concepts rawIdentifier Nothing -> do selection <- selectConcept fzfConfig resolvedBundle concepts case selection of- ConceptChosen concept -> renderConcept concept+ ConceptChosen concept -> render concept ConceptNoCandidates -> dieText ("No concepts found in " <> Text.pack resolvedBundle) ConceptSelectionCancelled -> exitWith (ExitFailure 130) ConceptSelectionUnavailable -> dieNoPicker "CONCEPT_ID" ConceptSelectionError message -> dieFzf message +-- | Report the OKF v0.2 trust tier (§5.3), lifecycle status (§5.4), and+-- staleness (§5.5) for every concept in a bundle, one aligned line each.+--+-- Reads the clock exactly once and passes the day to 'staleness' for every+-- concept, so a run that straddles midnight cannot report two concepts against+-- different notions of "today". @okf-core@ never reads the clock itself; see+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.+--+-- Output is sorted by concept ID, which 'walkBundle' already guarantees, so the+-- report is stable and diffable in pipelines and CI.+runTrust :: TrustOptions -> IO ()+runTrust TrustOptions {bundlePath} = do+ concepts <- loadBundleOrExit bundlePath+ today <- utctDay <$> getCurrentTime+ let rows = trustRow today <$> concepts+ widthOf column = maximum (0 : map (Text.length . column) rows)+ (idWidth, tierWidth, statusWidth) =+ (widthOf (\(a, _, _, _) -> a), widthOf (\(_, b, _, _) -> b), widthOf (\(_, _, c, _) -> c))+ mapM_+ ( \(conceptId, tier, status, stale) ->+ Text.IO.putStrLn+ ( Text.intercalate+ " "+ [ pad idWidth conceptId,+ pad tierWidth tier,+ pad statusWidth status,+ stale+ ]+ )+ )+ rows+ where+ pad width cell = cell <> Text.replicate (width - Text.length cell) " "+ trustRow today concept =+ ( renderConceptId (conceptIdOf concept),+ renderTrustTier (trustTier (conceptVerified concept)),+ renderStatus (conceptStatus concept),+ renderStaleness (staleness today (conceptStaleAfter concept))+ )++-- | List the OKF v0.2 @sources@ provenance recorded by each concept+-- (specification §5.1), with the credibility signals that frame it.+--+-- Concepts with no sources are skipped so the report shows only what has+-- provenance. Output is sorted by concept ID, which 'walkBundle' guarantees, so+-- the listing is stable and diffable in pipelines and CI.+--+-- Entries are printed in the order the document declares them and are never+-- sorted or ranked by @usage_count@. §5.1 warns that a count is a coarse signal+-- to be read "as liveness and trend, not as a score", and a ranked listing+-- would imply a precision the signal does not carry.+runSources :: SourcesOptions -> IO ()+runSources SourcesOptions {bundlePath} = do+ concepts <- loadBundleOrExit bundlePath+ let withSources = [concept | concept <- concepts, not (null (conceptSources concept))]+ labelWidth = maximum (0 : [Text.length (sourceLabel source) | concept <- withSources, source <- conceptSources concept])+ mapM_ (renderConceptSources labelWidth) withSources++renderConceptSources :: Int -> Concept -> IO ()+renderConceptSources labelWidth concept = do+ Text.IO.putStrLn (renderConceptId (conceptIdOf concept))+ mapM_ renderSource (conceptSources concept)+ where+ documentWindow = conceptUsageWindow concept+ renderSource source = do+ Text.IO.putStrLn (" " <> pad labelWidth (sourceLabel source) <> " " <> sourceResource source)+ -- Only signals actually present are named, so an entry with none prints+ -- no signals line rather than an empty one.+ case sourceSignals (effectiveUsageWindow documentWindow source) source of+ [] -> pure ()+ signals -> Text.IO.putStrLn (" " <> pad labelWidth "" <> " " <> Text.intercalate ", " signals)+ pad width cell = cell <> Text.replicate (width - Text.length cell) " "++-- | An entry's @id@, or a placeholder when it has none. §5.1 makes @id@+-- optional but recommends it when the body cites the source.+sourceLabel :: Source -> Text+sourceLabel Source {sourceId} = fromMaybe "(no id)" sourceId++-- | The credibility signals §5.1 defines, as display phrases, omitting each one+-- the entry does not carry. A @usage_count@ is always shown with the window+-- that frames it, since §5.1 makes a count without a window meaningless.+sourceSignals :: Maybe UsageWindow -> Source -> [Text]+sourceSignals window Source {sourceAuthor, sourceUsageCount, sourceLastModified} =+ concat+ [ ["author " <> renderActor author | Just author <- [sourceAuthor]],+ ["used " <> Text.pack (show count) <> " times" <> windowPhrase | Just count <- [sourceUsageCount]],+ ["modified " <> modified | Just modified <- [sourceLastModified]]+ ]+ where+ windowPhrase =+ case window of+ Just (UsageWindow (Just windowFrom) (Just windowTo)) -> " in " <> windowFrom <> ".." <> windowTo+ Just (UsageWindow (Just windowFrom) Nothing) -> " since " <> windowFrom+ Just (UsageWindow Nothing (Just windowTo)) -> " until " <> windowTo+ _ -> ""++-- | List the OKF v0.2 attested computations a bundle declares (specification+-- §10), one aligned line each.+--+-- This is specification §10.5 step 1 — \"Discover via @type: Attested+-- Computation@\" — asked of a whole bundle at once. Every other okf command+-- either takes one concept, reports every concept, or reports a different+-- family, so before this there was no way to ask a bundle what computations it+-- holds short of grepping @okf graph --json@.+--+-- Every column restates frontmatter and none says anything about a run. §10.5+-- marks the execute-and-attest workflow informative and puts the receipt and the+-- verdict outside the bundle entirely, so a column reading \"attests cleanly\"+-- would be a claim okf cannot make; see+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@ for the general+-- principle that okf derives on read and stores nothing it was not told.+--+-- Selection is on the exact @type@ string and nothing else. A @Metric@ that+-- happens to carry a @runtime@ key is not an attested computation, and §4.1+-- keeps no taxonomy okf could consult to decide otherwise.+--+-- Absences print as a parenthesised phrase rather than as an empty cell, because+-- a blank column hides exactly what @okf validate --strict@ reports: @(no+-- runtime)@ is the §10.2-REQUIRED field missing, and @(2 computations)@ is+-- §10.3's exactly-one rule broken.+--+-- Output is sorted by concept ID, which 'walkBundle' already guarantees, so the+-- report is stable and diffable in pipelines and CI. A bundle with no attested+-- computations prints nothing and exits zero, as @okf sources@ does for a bundle+-- with no provenance: an empty report is not an error.+runComputations :: ComputationsOptions -> IO ()+runComputations ComputationsOptions {bundlePath} = do+ concepts <- loadBundleOrExit bundlePath+ mapM_ Text.IO.putStrLn (computationReport concepts)++-- | The lines @okf computations@ prints, as data. Pure and separate from+-- 'runComputations' so a test can assert the whole report rather than only the+-- accessors behind it; 'renderProfileDetail' is exported for the same reason.+--+-- Concepts arrive in 'walkBundle' order and keep it. Column widths are computed+-- over the selected rows only, so one unrelated long concept ID elsewhere in the+-- bundle cannot pad this report.+computationReport :: [Concept] -> [Text]+computationReport concepts =+ [ Text.intercalate+ " "+ [ pad idWidth conceptId,+ pad runtimeWidth runtime,+ pad parameterWidth parameters,+ pad computationWidth computation,+ contract+ ]+ | (conceptId, runtime, parameters, computation, contract) <- rows+ ]+ where+ rows = computationRow <$> filter isAttestedComputation concepts+ widthOf column = maximum (0 : map (Text.length . column) rows)+ (idWidth, runtimeWidth, parameterWidth, computationWidth) =+ ( widthOf (\(a, _, _, _, _) -> a),+ widthOf (\(_, b, _, _, _) -> b),+ widthOf (\(_, _, c, _, _) -> c),+ widthOf (\(_, _, _, d, _) -> d)+ )+ pad width cell = cell <> Text.replicate (width - Text.length cell) " "+ isAttestedComputation concept = conceptType concept == attestedComputationType+ computationRow concept =+ ( renderConceptId (conceptIdOf concept),+ fromMaybe "(no runtime)" (conceptRuntime concept),+ case conceptParameters concept of+ [] -> "(no parameters)"+ parameters -> Text.intercalate ", " (renderParameter <$> parameters),+ computationLocation (conceptComputationSources concept),+ contractHalves concept+ )+ -- Where §10.3 says the computation lives. Naming the count rather than+ -- picking one of two is the honest answer: a concept offering both is what+ -- `okf validate --strict` reports, and `okf show --computation` refuses it+ -- for the same reason.+ computationLocation = \case+ [] -> "(no computation)"+ [ComputationFile rawPath] -> rawPath+ [ComputationInline _] -> "inline"+ sources -> "(" <> countPhrase (length sources) "computation" <> ")"+ -- Which of §10.2's two run-and-check halves the concept declares. Neither is+ -- REQUIRED, so all four combinations are legitimate and the phrase says which+ -- one this is rather than passing judgement on it.+ contractHalves concept =+ case (isJust (conceptExecutor concept), isJust (conceptAttester concept)) of+ (True, True) -> "executor + attester"+ (True, False) -> "executor"+ (False, True) -> "attester"+ (False, False) -> "(neither)"+ -- | Use the given bundle, or ask the user to pick one. resolveBundlePath :: FzfConfig -> Maybe FilePath -> IO FilePath resolveBundlePath _ (Just path) = pure path@@ -1043,10 +1608,14 @@ -- first, then a profile-declared document ID. Unchanged from the previous -- implementation of 'runShow', so the resolution order fixed by ADR 1 cannot -- drift.-showConceptByIdentifier :: Maybe FilePath -> [Concept] -> Text -> IO ()-showConceptByIdentifier profilePath concepts conceptIdText =+--+-- The renderer is a parameter rather than 'renderConcept' directly, so that+-- @okf show --computation@ resolves an identifier by exactly the same rules as+-- @okf show@.+showConceptByIdentifier :: (Concept -> IO ()) -> Maybe FilePath -> [Concept] -> Text -> IO ()+showConceptByIdentifier renderChosen profilePath concepts conceptIdText = case either (const Nothing) (`findConcept` concepts) (parseConceptId conceptIdText) of- Just concept -> renderConcept concept+ Just concept -> renderChosen concept Nothing -> case parseDocumentId conceptIdText of Nothing ->@@ -1070,7 +1639,7 @@ <> conceptIdText <> " (no document carries that document ID)" )- [concept] -> renderConcept concept+ [concept] -> renderChosen concept matches -> dieText ( "Ambiguous document ID "@@ -1124,13 +1693,39 @@ Left bundleError -> dieText (renderBundleError bundleError) Right concepts -> pure concepts -loadIndexesOrExit :: FilePath -> IO [(FilePath, Text)]-loadIndexesOrExit bundlePath = do- result <- renderBundleIndexes bundlePath+-- | Every file the bundle holds, so a path-valued frontmatter field can be+-- resolved against a target that is not a concept. A second traversal of the+-- same tree 'loadBundleOrExit' walks; see 'walkBundleInventory'.+loadBundleInventoryOrExit :: FilePath -> IO BundleInventory+loadBundleInventoryOrExit bundlePath = do+ result <- walkBundleInventory bundlePath case result of Left bundleError -> dieText (renderBundleError bundleError)+ Right inventory -> pure inventory++loadIndexesOrExit :: Maybe OkfVersion -> FilePath -> IO [(FilePath, Text)]+loadIndexesOrExit override bundlePath = do+ result <- renderBundleIndexesWith override bundlePath+ case result of+ Left bundleError -> dieText (renderBundleError bundleError) Right indexes -> pure indexes +loadBundleVersionOrExit :: FilePath -> IO VersionDeclaration+loadBundleVersionOrExit bundlePath = do+ result <- readBundleVersion bundlePath+ case result of+ Left bundleError -> dieText (renderBundleError bundleError)+ Right declaration -> pure declaration++-- | The version suffix on a successful @validate@. Appended only when a bundle+-- declares one, so output for every bundle that declares nothing — which is+-- almost all of them — stays byte-identical for the pipelines and agents that+-- read it (@docs\/adr\/2-interactive-bundle-and-concept-selection.md@).+renderDeclaredVersion :: VersionDeclaration -> Text+renderDeclaredVersion = \case+ VersionDeclared version -> " (okf_version " <> renderOkfVersion version <> ")"+ _ -> ""+ loadLogsOrExit :: FilePath -> IO [LogFile] loadLogsOrExit bundlePath = do result <- walkLogs bundlePath@@ -1144,11 +1739,44 @@ renderConceptId conceptId <> ": " <> renderValidationErrorText error_ DanglingReference source target -> renderConceptId source <> ": link to missing concept: " <> renderConceptId target+ DanglingFrontmatterPath source fieldName target alternative ->+ renderConceptId source+ <> ": "+ <> fieldName+ <> " names "+ <> Text.pack target+ <> ", which does not exist in this bundle"+ <> foldMap renderPathAlternative alternative DuplicateConceptId conceptId -> "duplicate concept ID: " <> renderConceptId conceptId LogInvalid path error_ -> Text.pack path <> ": " <> renderLogValidationError error_+ BundleVersionUnparseable rawVersion ->+ "index.md: okf_version is not of the form MAJOR.MINOR: " <> rawVersion+ BundleVersionNotUnderstood version ->+ "index.md: okf_version "+ <> version+ <> " has a major version okf does not understand; reading the bundle permissively" +-- | The trailing hint on a dangling frontmatter path, naming the bundle-relative+-- spelling that would have resolved.+--+-- Specification §10.2's own worked example writes a bare @references\/@ path+-- while §10.4 puts computations in their own folder, so an author copying the+-- specification is told about a path they never wrote. The hint says what to+-- write instead; §6.2 resolution itself is unchanged.+--+-- The leading @\/@ is added here rather than carried in the diagnostic, because+-- 'DanglingFrontmatterPath' holds both of its paths as resolved bundle-relative+-- targets and they should mean the same thing. What an author must /write/ to+-- get that target is §6.2's bundle-relative form, and writing the bare text back+-- would name exactly what they wrote already.+renderPathAlternative :: FilePath -> Text+renderPathAlternative alternative =+ " (/"+ <> Text.pack alternative+ <> " does — a path with no leading slash resolves against the concept's own directory)"+ bundleValidationErrorIsFailure :: BundleValidationError -> Bool bundleValidationErrorIsFailure = \case LogInvalid _ error_ -> Log.logErrorIsStructural error_@@ -1181,11 +1809,13 @@ <> ": missing profile-required field: " <> renderFieldPath fieldPath <> renderConditionContext condition+ <> renderNestedDescription cid fieldPath MissingRecommendedNestedProfileField cid fieldPath condition -> renderConceptId cid <> ": missing profile-recommended field: " <> renderFieldPath fieldPath <> renderConditionContext condition+ <> renderNestedDescription cid fieldPath ValueNotInVocabulary cid fieldPath allowed actual -> renderConceptId cid <> ": frontmatter value at "@@ -1247,6 +1877,29 @@ <> renderFieldPath fieldPath <> " is not allowed: " <> handle+ -- Phrased to mirror 'DanglingHandleReference' above, so a reader can tell at+ -- a glance which kind of reference okf was resolving: a handle is reported as+ -- "references ADR-7, which does not exist in this bundle", a path as+ -- "references /references/x.md, which does not exist in this bundle".+ DanglingPathReference cid fieldPath rawPath ->+ renderConceptId cid+ <> ": "+ <> renderFieldPath fieldPath+ <> " references "+ <> rawPath+ <> ", which does not exist in this bundle"+ MalformedPathReference cid fieldPath actual ->+ renderConceptId cid+ <> ": malformed path at "+ <> renderFieldPath fieldPath+ <> ": "+ <> Text.pack (LazyByteString.unpack (Aeson.encode actual))+ PathEscapesBundle cid fieldPath rawPath ->+ renderConceptId cid+ <> ": path at "+ <> renderFieldPath fieldPath+ <> " climbs above the bundle root: "+ <> rawPath FieldNotInProfile cid key -> renderConceptId cid <> ": frontmatter field not declared by profile: " <> key NestedElementNotRecord cid fieldPath actual ->@@ -1277,11 +1930,44 @@ renderConceptId cid <> ": document ID must look like " <> prefix <> "-<number>, found: " <> actual DuplicateDocumentId handle cid other -> renderConceptId cid <> ": duplicate document ID " <> handle <> " (also on " <> renderConceptId other <> ")"+ -- The one violation that names no concept, so it opens with "bundle" where+ -- every other line opens with a concept ID. Both halves are named for the same+ -- reason the version definition errors name both: the requirement is in the+ -- profile and the declaration is in the bundle's root index, and a reader+ -- holding one line should not have to hunt for the other end.+ RequiredBundleVersionUnmet required declared ->+ case declared of+ Nothing ->+ "bundle does not declare okf_version; this profile requires " <> required <> " or later"+ Just declaredVersion ->+ "bundle declares okf_version "+ <> declaredVersion+ <> "; this profile requires "+ <> required+ <> " or later" where renderDescription cid key = maybe "" (\prose -> " (" <> prose <> ")") $ do ctype <- lookup cid [(conceptIdOf concept, conceptType concept) | concept <- concepts] profileFieldDescriptionForType compiled ctype key+ -- The prose a profile attached to a member of a record, so that a nested+ -- key explains itself exactly as a top-level one does. The path is+ -- @parent.member@ for an object-valued key and @parent[index].member@ for a+ -- list element; both name the same member rule, so the index is skipped.+ renderNestedDescription cid fieldPath =+ maybe "" (\prose -> " (" <> prose <> ")") $ do+ ctype <- lookup cid [(conceptIdOf concept, conceptType concept) | concept <- concepts]+ (parentKey, memberKey) <- nestedPathKeys fieldPath+ parentRule <- Map.lookup parentKey (compiledProfileRulesForType compiled ctype)+ memberRule <-+ Map.lookup memberKey+ =<< (fieldRuleObjectFields parentRule <|> fieldRuleElementFields parentRule)+ fieldRuleDescription memberRule+ nestedPathKeys (FieldPath segments) =+ case NonEmpty.toList segments of+ [FieldName parentKey, FieldName memberKey] -> Just (parentKey, memberKey)+ [FieldName parentKey, ArrayIndex _, FieldName memberKey] -> Just (parentKey, memberKey)+ _ -> Nothing renderConditionContext = maybe "" renderCondition renderCondition FieldCondition {field = sourceField, hasValue = [expected]} = " (when " <> sourceField <> " is " <> expected <> ")"@@ -1320,6 +2006,12 @@ <> renderFieldPath fieldPath <> " requires list cardinality, found: " <> renderCardinality actualCardinality+ ObjectFieldsRequireObjectShape scope fieldPath actualCardinality ->+ renderScope scope+ <> ": objectFields at "+ <> renderFieldPath fieldPath+ <> " cannot be combined with cardinality "+ <> renderCardinality actualCardinality InvalidFormatParameter fieldPath fieldFormat parameter -> "invalid parameter for format " <> renderFieldFormat fieldFormat@@ -1386,6 +2078,45 @@ renderScope scope <> ": optional field cannot carry a when condition: " <> renderFieldPath target+ PathReferenceWithHandleReference scope target ->+ renderScope scope+ <> ": path at "+ <> renderFieldPath target+ <> " cannot also declare a document reference; a value is resolved as one or the other"+ -- Each version message names *both* halves of the contradiction. One that said+ -- only "field requires OKF 0.2" would leave the author hunting for where the+ -- version is declared, which is the other end of the file.+ InvalidProfileOkfVersion rawVersion ->+ "okfVersion is not <major>.<minor>: " <> rawVersion+ ProfileOkfVersionNotUnderstood rawVersion ->+ "okfVersion "+ <> rawVersion+ <> " names an OKF major version this okf does not implement (supported: "+ <> renderOkfVersion supportedOkfVersion+ <> ")"+ InvalidRequiredBundleVersion rawVersion ->+ "requireBundleVersion is not <major>.<minor>: " <> rawVersion+ FieldSupersededInOkfVersion scope target declared supersededIn ->+ renderScope scope+ <> ": declared okfVersion "+ <> declared+ <> " supersedes the frontmatter key "+ <> renderFieldPath target+ <> " (OKF "+ <> supersededIn+ <> "); move it to the optional list"+ <> maybe "" (\replacement -> " or replace it with " <> replacement) (supersededBy (renderFieldPath target))+ FormatRequiresOkfVersion scope target fieldFormat declared introducedIn ->+ renderScope scope+ <> ": declared okfVersion "+ <> declared+ <> " does not support the format "+ <> renderFieldFormat fieldFormat+ <> " at "+ <> renderFieldPath target+ <> ", which OKF "+ <> introducedIn+ <> " introduced" where renderScope Nothing = "profile frontmatter" renderScope (Just ctype) = "type " <> ctype <> " frontmatter"@@ -1401,6 +2132,7 @@ Any -> "any" Scalar -> "scalar" List -> "list"+ Object -> "object" renderFieldFormat :: FieldFormat -> Text renderFieldFormat = \case@@ -1409,6 +2141,11 @@ Uri -> "uri" UriWithScheme scheme -> "uri-with-scheme(" <> scheme <> ")" DocumentHandle prefix -> "document-handle(" <> prefix <> ")"+ Actor -> "actor"+ HumanActor -> "human-actor"+ Integer -> "integer"+ NonNegativeInteger -> "non-negative-integer"+ Boolean -> "boolean" renderHandleReferenceRule :: HandleReferenceRule -> Text renderHandleReferenceRule HandleReferenceRule {localPrefix, externalUriSchemes, allowSelf} =@@ -1422,6 +2159,19 @@ where renderList xs = "[" <> Text.intercalate ", " xs <> "]" +-- | Deliberately shaped like 'renderHandleReferenceRule' minus the local prefix+-- a path has no analogue for, so an author who has read one recognizes the+-- other and can see at a glance which kind of rule a key carries.+renderPathReferenceRule :: PathReferenceRule -> Text+renderPathReferenceRule PathReferenceRule {externalUriSchemes, allowSelf} =+ "external-uri-schemes("+ <> renderList externalUriSchemes+ <> "), allow-self("+ <> (if allowSelf then "true" else "false")+ <> ")"+ where+ renderList xs = "[" <> Text.intercalate ", " xs <> "]"+ valueCardinalityName :: Aeson.Value -> Text valueCardinalityName = \case Aeson.Array _ -> "list"@@ -1446,7 +2196,36 @@ FieldMustBeNonEmptyText fieldName -> "field must be non-empty text: " <> fieldName MissingRecommendedField fieldName -> "missing recommended field: " <> fieldName FieldMustBeListOfText fieldName -> "field must be a list of text values: " <> fieldName+ MissingGeneratedField -> "missing generated field (or legacy timestamp)"+ GeneratedMustHaveActor -> "generated must carry a by actor"+ SourceMissingResource entryIndex -> "sources entry is missing resource: index " <> Text.pack (show entryIndex)+ DuplicateSourceId sourceId -> "duplicate sources id: " <> sourceId+ FootnoteLabelNotInSources label -> "footnote label has no matching sources id: " <> label+ SourceIdNotCited sourceId -> "lint: sources id is never cited by a footnote: " <> sourceId+ LegacyFieldInDeclaredV2 fieldName ->+ "legacy v0.1 field in a bundle declaring okf_version 0.2 or later: "+ <> fieldName+ <> maybe "" (\replacement -> " (use " <> replacement <> ")") (supersededBy fieldName)+ AttestedComputationMissingRuntime ->+ attestedComputationType <> " concepts must declare runtime"+ AttestedComputationHasNoComputation ->+ attestedComputationType+ <> " declares no computation: add a code block under a # Computation heading, or a computation path"+ AttestedComputationHasBothComputations ->+ attestedComputationType+ <> " declares a computation both inline and by path; exactly one is permitted"+ AttestedComputationHasManyBlocks blockCount ->+ attestedComputationType+ <> " has "+ <> Text.pack (show blockCount)+ <> " code blocks under # Computation; exactly one is permitted" +-- | The OKF v0.2 field that supersedes a v0.1 one (specification §13.1).+supersededBy :: Text -> Maybe Text+supersededBy = \case+ "timestamp" -> Just "generated"+ _ -> Nothing+ renderLogValidationError :: Log.LogValidationError -> Text renderLogValidationError = \case Log.LogDateNotIso dateText -> "log date heading is not YYYY-MM-DD: " <> dateText@@ -1456,7 +2235,7 @@ renderLogStaleness :: LogStaleness -> Text renderLogStaleness LogStaleness {staleConcept, staleConceptDate, staleLogPath, staleLogDate} = renderConceptId staleConcept- <> ": timestamp date "+ <> ": generated date " <> staleConceptDate <> case (staleLogPath, staleLogDate) of (Nothing, Nothing) -> " has no enclosing log.md"@@ -1484,8 +2263,146 @@ traverse_ (Text.IO.putStrLn . ("description: " <>)) (conceptDescription concept) traverse_ (Text.IO.putStrLn . ("resource: " <>)) (conceptResource concept) unless (null (conceptTags concept)) (Text.IO.putStrLn ("tags: " <> Text.intercalate ", " (conceptTags concept)))+ -- The OKF v0.2 attested computation contract (specification §10.2), in §10.2's+ -- own field order. Every line is printed only when the concept declares it, so+ -- output for a concept that is not an Attested Computation is unchanged.+ traverse_ (Text.IO.putStrLn . ("runtime: " <>)) (conceptRuntime concept)+ unless+ (null (conceptParameters concept))+ (Text.IO.putStrLn ("parameters: " <> Text.intercalate ", " (renderParameter <$> conceptParameters concept)))+ -- Where the computation lives, in both of §10.3's forms. Without the inline+ -- line a reader cannot tell "carries a computation in its body" from "carries+ -- none at all". Exactly one line is printed: a concept somehow offering both+ -- is `okf validate --strict`'s diagnostic, and `okf show` is not a validator.+ case conceptComputationSources concept of+ ComputationFile rawPath : _ -> Text.IO.putStrLn ("computation: " <> rawPath)+ ComputationInline literal : _ ->+ Text.IO.putStrLn ("computation: inline (" <> countPhrase (length (Text.lines literal)) "line" <> ")")+ [] -> pure ()+ traverse_ (Text.IO.putStrLn . ("executor: " <>) . renderExecutor) (conceptExecutor concept)+ traverse_ (Text.IO.putStrLn . ("attester: " <>)) (attesterResource =<< conceptAttester concept)+ traverse_ (Text.IO.putStrLn . ("generated: " <>) . renderGenerated) (conceptGenerated concept)+ Text.IO.putStrLn ("trust: " <> renderTrustTier (trustTier (conceptVerified concept)))+ traverse_ (Text.IO.putStrLn . ("verified: " <>)) (latestVerification (conceptVerified concept))+ Text.IO.putStrLn ("status: " <> renderStatus (conceptStatus concept))+ -- Staleness is a comparison against today, so this is the one line that needs+ -- the clock. okf-core never reads it; the CLI does, here and in `okf trust`.+ today <- utctDay <$> getCurrentTime+ let staleAfterRaw = conceptStaleAfter concept+ case staleness today staleAfterRaw of+ NoStaleAfter -> pure ()+ stale -> Text.IO.putStrLn ("stale_after: " <> renderStaleAfterDetail staleAfterRaw stale)+ case length (conceptSources concept) of+ 0 -> pure ()+ sourceCount ->+ Text.IO.putStrLn+ ("sources: " <> countPhrase sourceCount "source" <> " (see `okf sources`)") Text.IO.putStrLn "" Text.IO.putStr (bodyText concept)++-- | Print a concept's computation and nothing else, in whichever of+-- specification §10.3's two forms the producer chose.+--+-- This is the half of §10.5's consumer workflow a caller would otherwise+-- reimplement: "Load the contract from frontmatter and the computation from the+-- body (or the file named by @computation@)". The parenthesis is the expensive+-- part, because it drags in §6.2's path grammar — where a bare+-- @references\/x.sql@ resolves against the concept's own directory rather than+-- the bundle root — and okf already owns that grammar in "Okf.Path".+--+-- Reading a file the bundle holds is not executing anything. §10's boundary is+-- that OKF "records the computation and the means to check it; it does not+-- execute anything itself", which is about running computations rather than+-- about opening files. The IO is CLI-side because @okf-core@ validation is+-- offline by design; see+-- @docs\/adr\/5-compile-profile-rules-before-validation.md@.+--+-- Offering no computation, or more than one, exits non-zero rather than+-- guessing. Printing an arbitrary one of two would answer a question the bundle+-- has not settled.+renderComputation :: FilePath -> Concept -> IO ()+renderComputation bundleRoot concept =+ case conceptComputationSources concept of+ [ComputationInline literal] -> Text.IO.putStr literal+ [ComputationFile rawPath] ->+ case classifyPathReference (conceptIdOf concept) rawPath of+ BundlePath target -> do+ let absolutePath = bundleRoot </> target+ exists <- doesFileExist absolutePath+ unless exists $+ dieText (conceptPrefix <> "computation names " <> Text.pack target <> ", which the bundle does not hold")+ Text.IO.putStr =<< Text.IO.readFile absolutePath+ ExternalUrl scheme ->+ dieText+ ( conceptPrefix+ <> "computation is a "+ <> scheme+ <> " URL; okf has no network access and never fetches a computation"+ )+ EscapesBundle ->+ dieText (conceptPrefix <> "computation names a path that climbs above the bundle root")+ MalformedPath ->+ dieText (conceptPrefix <> "computation is not a usable path")+ [] ->+ dieText+ ( conceptPrefix+ <> "offers no computation: it names no computation path and carries no code block under a # Computation heading"+ )+ sources ->+ dieText+ ( conceptPrefix+ <> "offers more than one computation, so okf cannot say which one to run: "+ <> Text.intercalate " and " (describeSource <$> sources)+ <> "\nRun okf validate --strict to see this as a diagnostic."+ )+ where+ conceptPrefix = renderConceptId (conceptIdOf concept) <> ": "+ describeSource = \case+ ComputationFile rawPath -> "the file " <> rawPath+ ComputationInline literal ->+ "an inline block of " <> countPhrase (length (Text.lines literal)) "line"++-- | Render one attested-computation parameter as @\<name\> (\<type\>,+-- required)@, dropping whichever of the two optional members the concept omits+-- (specification §10.2).+renderParameter :: Parameter -> Text+renderParameter Parameter {parameterName, parameterType, parameterRequired} =+ parameterName <> if null qualifiers then "" else " (" <> Text.intercalate ", " qualifiers <> ")"+ where+ qualifiers =+ toList parameterType+ <> ["required" | parameterRequired == Just True]+ <> ["optional" | parameterRequired == Just False]++-- | Render the OKF v0.2 @executor@ as its resource followed by the receipt+-- fields a run must return (specification §10.2). Either half may be absent, so+-- an executor declaring only one still renders as something a reader can act on.+renderExecutor :: Executor -> Text+renderExecutor Executor {executorResource, executorReceipt} =+ Text.intercalate+ ", "+ ( toList executorResource+ <> ["receipt: " <> Text.intercalate ", " executorReceipt | not (null executorReceipt)]+ )++-- | Render the OKF v0.2 @generated@ family as @\<actor\>@, or+-- @\<actor\> at \<datetime\>@ when the family carries an @at@.+renderGenerated :: Generated -> Text+renderGenerated Generated {generatedBy, generatedAt} =+ renderActor generatedBy <> maybe "" (" at " <>) generatedAt++-- | Render staleness for a single concept, showing the deadline itself rather+-- than the summary phrase @okf trust@ uses in its column.+-- The raw value is shown in every case, so the line always says what the+-- document actually declares, with the verdict as a suffix.+renderStaleAfterDetail :: Maybe Text -> Staleness -> Text+renderStaleAfterDetail rawValue = \case+ NoStaleAfter -> ""+ Fresh -> raw <> " (ok)"+ Stale _ -> raw <> " (stale)"+ StaleAfterUnparseable _ -> raw <> " (unparseable)"+ where+ raw = fromMaybe "" rawValue bodyText :: Concept -> Text bodyText concept =
src/Okf/Cli/Help.hs view
@@ -48,6 +48,12 @@ HelpTopic "format" "Bundle layout, concept IDs, frontmatter, and links" formatTopicContent, HelpTopic "validation" "How bundles are validated and referential integrity" validationTopicContent, HelpTopic "profiles" "Checking a bundle against house conventions" profilesTopicContent,+ HelpTopic "computations" "Listing and printing attested computations" computationsTopicContent,+ HelpTopic "trust" "Trust tiers, staleness, and recorded provenance" trustTopicContent,+ HelpTopic "index" "Generated index.md files and the version declaration" indexTopicContent,+ HelpTopic "log" "log.md upkeep and the two staleness checks" logTopicContent,+ HelpTopic "graph" "The concept graph and what becomes an edge" graphTopicContent,+ HelpTopic "ids" "Profile-declared document IDs such as ADR-7" idsTopicContent, HelpTopic "interactive" "Picking a bundle and concept with fzf" interactiveTopicContent, HelpTopic "config" "Config files, defaults, and agent settings" configTopicContent, HelpTopic "kit" "Installing and publishing agent skills and subagents" kitTopicContent,@@ -66,6 +72,24 @@ profilesTopicContent :: Text profilesTopicContent = $(embedStringFile "help/profiles.md") +computationsTopicContent :: Text+computationsTopicContent = $(embedStringFile "help/computations.md")++trustTopicContent :: Text+trustTopicContent = $(embedStringFile "help/trust.md")++indexTopicContent :: Text+indexTopicContent = $(embedStringFile "help/index.md")++logTopicContent :: Text+logTopicContent = $(embedStringFile "help/log.md")++graphTopicContent :: Text+graphTopicContent = $(embedStringFile "help/graph.md")++idsTopicContent :: Text+idsTopicContent = $(embedStringFile "help/ids.md")+ interactiveTopicContent :: Text interactiveTopicContent = $(embedStringFile "help/interactive.md") @@ -101,9 +125,10 @@ listTopics = do Text.IO.putStrLn "HELP TOPICS\n" forM_ helpTopics $ \t ->- Text.IO.putStrLn (" " <> padRight 12 (topicName t) <> topicDescription t)+ Text.IO.putStrLn (" " <> padRight 14 (topicName t) <> topicDescription t) Text.IO.putStrLn "\nUse 'okf help <topic>' for details." where+ -- 14, so the longest topic name ("computations") still gets two spaces. padRight n t = t <> Text.replicate (max 0 (n - Text.length t)) " " showTopic :: Text -> IO ()
test/Main.hs view
@@ -2,21 +2,24 @@ import Control.Exception (bracket) import Control.Monad (unless)+import Data.List qualified as List import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO-import Okf.Bundle (conceptFromDocument)+import Okf.Bundle (bundleInventoryOfConcepts, conceptAttester, conceptExecutor, conceptFromDocument, conceptIdOf, conceptParameters, conceptRuntime, conceptType, walkBundle, walkBundleInventory) import Okf.Cli import Okf.Cli.Assist (AssistOptions (..), buildClaudeCommand) import Okf.Cli.Config (AssistSettings (..), ConfigSource (..), KitSettings (..), OkfConfig (..), OkfProvider (..), defaultOkfConfig, exampleConfigText, findConfigSource, loadOkfConfig, okfConfigEnvVar, projectConfigPath) import Okf.Cli.Fzf (Candidate (..), FzfOpts (..), optsToArgs, parseSelectionIndex, renderCandidateLines, shellQuote, withAnsi, withHeight, withNoSort, withPrompt) import Okf.Cli.Fzf.Selector (conceptCandidates, conceptPreviewCommand, parseBundleSearchRoots) import Okf.Cli.Help (HelpTopic (..), helpTopics)-import Okf.ConceptId (parseConceptId)-import Okf.Document (parseDocument)-import Okf.Profile (Cardinality (..), FieldCondition (..), FieldFormat (..), FieldRule (..), FrontmatterRules (..), HandleReferenceRule (..), NestedFieldRule (..), NestedRules (..), ProfileSpec (..), TypeRule (..))+import Okf.ConceptId (parseConceptId, renderConceptId)+import Okf.Document (Attester (..), Executor (..), Parameter (..), parseDocument)+import Okf.Index (OkfVersion (..), VersionDeclaration (..), parseOkfVersion, readBundleVersion)+import Okf.Profile (Cardinality (..), FieldCondition (..), FieldFormat (..), FieldRule (..), FrontmatterRules (..), HandleReferenceRule (..), NestedFieldRule (..), NestedRules (..), PathReferenceRule (..), ProfileSpec (..), TypeRule (..), compileProfile, loadProfileFile, validateProfile, validateProfileVersion) import Okf.Profile.Registry (RegistryEntry (..))+import Okf.Validation (ValidationProfile (..), validateBundle) import Options.Applicative-import System.Directory (createDirectoryIfMissing, getCurrentDirectory, getTemporaryDirectory, removeDirectoryRecursive, withCurrentDirectory)+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, withCurrentDirectory) import System.Environment (lookupEnv, setEnv, unsetEnv) import System.Exit (exitFailure) import System.FilePath ((</>))@@ -32,6 +35,17 @@ configInvalidDhall <- testConfigInvalidDhall assistCommandBuilder <- testAssistCommandBuilder assistModelOverride <- testAssistModelOverride+ profileDocumentWrites <- testProfileDocumentWritesBundle+ profileDocumentDeclaresVersion <- testProfileDocumentDeclaresOkfVersion+ profileDocMatchesExample <- testProfileDocumentationMatchesCommittedExample+ profileDocConformsToMeta <- testProfileDocumentationConformsToMetaProfile+ referenceProfileCompiles <- testReferenceProfileCompiles+ shippedProfileRequiresVersion <- testShippedProfileRequiresBundleVersion+ referenceProfileAcceptsExample <- testReferenceProfileAcceptsDddOrdering+ exampleAttestedComputation <- testExampleAttestedComputationValidates+ computationsReportsFixtures <- testComputationsReportsFixtureBundle+ computationsReportsExample <- testComputationsReportsExampleBundle+ profileDocStrictWithTimestamp <- testProfileDocumentationStrictWithTimestamp let results = [ parseSucceeds ["validate", "bundle"], parseSucceeds ["validate", "bundle", "--strict"],@@ -97,24 +111,51 @@ ShowOptions { bundlePath = Just "bundle", conceptIdText = Just "tables/orders",- profilePath = Nothing+ profilePath = Nothing,+ computationOnly = False }, parseShowMatches ["show", "b", "ADR-2", "--profile", "p.dhall"] ShowOptions { bundlePath = Just "b", conceptIdText = Just "ADR-2",- profilePath = Just "p.dhall"+ profilePath = Just "p.dhall",+ computationOnly = False }, parseShowMatches ["show"]- ShowOptions {bundlePath = Nothing, conceptIdText = Nothing, profilePath = Nothing},+ ShowOptions+ { bundlePath = Nothing,+ conceptIdText = Nothing,+ profilePath = Nothing,+ computationOnly = False+ }, parseShowMatches ["show", "bundle"]- ShowOptions {bundlePath = Just "bundle", conceptIdText = Nothing, profilePath = Nothing},+ ShowOptions+ { bundlePath = Just "bundle",+ conceptIdText = Nothing,+ profilePath = Nothing,+ computationOnly = False+ }, parseShowMatches ["show", "--profile", "p.dhall"]- ShowOptions {bundlePath = Nothing, conceptIdText = Nothing, profilePath = Just "p.dhall"},+ ShowOptions+ { bundlePath = Nothing,+ conceptIdText = Nothing,+ profilePath = Just "p.dhall",+ computationOnly = False+ },+ -- §10.3's two forms are both reachable through one flag, so a caller+ -- does not have to know which one the producer chose.+ parseShowMatches+ ["show", "bundle", "computations/revenue", "--computation"]+ ShowOptions+ { bundlePath = Just "bundle",+ conceptIdText = Just "computations/revenue",+ profilePath = Nothing,+ computationOnly = True+ }, parseIdMatches ["id", "next", "b", "ADR", "--profile", "p.dhall"] IdOptions@@ -194,6 +235,90 @@ parseProfileMatches ["profile", "show"] (ProfileShow (ProfileShowOptions Nothing Nothing False)),+ parseSucceeds ["profile", "document"],+ parseSucceeds ["profile", "document", "acme"],+ parseSucceeds ["profile", "document", "--profile", "p.dhall"],+ parseSucceeds ["profile", "document", "--out", "docs/p", "--write"],+ parseSucceeds+ ["profile", "document", "--registry", "./r.dhall", "acme", "--out", "d", "--write", "--timestamp", "2026-07-31T00:00:00Z"],+ parseProfileMatches+ ["profile", "document", "--registry", "./r.dhall", "acme", "--out", "d", "--write", "--timestamp", "2026-07-31T00:00:00Z"]+ ( ProfileDocument+ ProfileDocumentOptions+ { registryRef = Just "./r.dhall",+ export = Just "acme",+ profilePath = Nothing,+ outputPath = Just "d",+ write = True,+ timestamp = Just "2026-07-31T00:00:00Z",+ generatedBy = Nothing,+ generatedAt = Nothing,+ okfVersion = Nothing+ }+ ),+ -- The three provenance and version flags reach the options record.+ parseProfileMatches+ [ "profile",+ "document",+ "--profile",+ "p.dhall",+ "--out",+ "d",+ "--write",+ "--generated-by",+ "okf/0.5.0.0",+ "--generated-at",+ "2026-08-01T00:00:00Z",+ "--okf-version",+ "0.2"+ ]+ ( ProfileDocument+ ProfileDocumentOptions+ { registryRef = Nothing,+ export = Nothing,+ profilePath = Just "p.dhall",+ outputPath = Just "d",+ write = True,+ timestamp = Nothing,+ generatedBy = Just "okf/0.5.0.0",+ generatedAt = Just "2026-08-01T00:00:00Z",+ okfVersion = Just "0.2"+ }+ ),+ parseProfileMatches+ ["profile", "document"]+ ( ProfileDocument+ ProfileDocumentOptions+ { registryRef = Nothing,+ export = Nothing,+ profilePath = Nothing,+ outputPath = Nothing,+ write = False,+ timestamp = Nothing,+ generatedBy = Nothing,+ generatedAt = Nothing,+ okfVersion = Nothing+ }+ ),+ parseProfileMatches+ ["profile", "document", "--profile", "p.dhall"]+ ( ProfileDocument+ ProfileDocumentOptions+ { registryRef = Nothing,+ export = Nothing,+ profilePath = Just "p.dhall",+ outputPath = Nothing,+ write = False,+ timestamp = Nothing,+ generatedBy = Nothing,+ generatedAt = Nothing,+ okfVersion = Nothing+ }+ ),+ parseSucceeds ["trust", "bundle"],+ parseSucceeds ["sources", "bundle"],+ parseSucceeds ["computations", "bundle"],+ parseFails ["computations"], renderRegistryTable sampleRegistryEntries == sampleRegistryTable, renderProfileDetail "nested.decisions" sampleDecisionsProfile == sampleProfileDetail, renderProfileDetail "" samplePostgresqlProfile == sampleUndocumentedProfileDetail,@@ -201,6 +326,17 @@ parseShowsInfo ["--version"], parseFails ["hello"], logAddWrites,+ profileDocumentWrites,+ profileDocumentDeclaresVersion,+ profileDocMatchesExample,+ profileDocConformsToMeta,+ referenceProfileCompiles,+ shippedProfileRequiresVersion,+ referenceProfileAcceptsExample,+ exampleAttestedComputation,+ computationsReportsFixtures,+ computationsReportsExample,+ profileDocStrictWithTimestamp, configDefaults, configProjectPrecedence, configEnvPrecedence,@@ -275,6 +411,7 @@ allowUnknownTypes = False, allowUnknownFields = True, idField = Nothing,+ requireBundleVersion = Nothing, types = [ TypeRule { type_ = "PostgreSQL Table",@@ -305,7 +442,9 @@ cardinality = Any, format = Nothing, elementFields = Nothing,+ objectFields = Nothing, reference = Nothing,+ path = Nothing, when = Nothing }, undocumentedField "title"@@ -319,7 +458,9 @@ cardinality = Scalar, format = Nothing, elementFields = Nothing,+ objectFields = Nothing, reference = Nothing,+ path = Nothing, when = Nothing } ]@@ -327,15 +468,16 @@ allowUnknownTypes = False, allowUnknownFields = True, idField = Just "docId",+ requireBundleVersion = Nothing, types = [ TypeRule { type_ = "Decision Record", description = Just "One accepted decision, never edited after acceptance.", frontmatter = FrontmatterRules- { required = [FieldRule "owner" (Just "Person responsible for the decision.") [] Scalar (Just (DocumentHandle "USR")) Nothing Nothing Nothing],- recommended = [FieldRule "reviewer" Nothing ["Ari", "Bo"] List Nothing Nothing (Just (HandleReferenceRule "ADR" ["mori"] False)) Nothing],- optional = [FieldRule "supersedes" Nothing [] Scalar Nothing Nothing (Just (HandleReferenceRule "ADR" [] False)) Nothing]+ { required = [FieldRule "owner" (Just "Person responsible for the decision.") [] Scalar (Just (DocumentHandle "USR")) Nothing Nothing Nothing Nothing Nothing],+ recommended = [FieldRule "reviewer" Nothing ["Ari", "Bo"] List Nothing Nothing Nothing (Just (HandleReferenceRule "ADR" ["mori"] False)) Nothing Nothing],+ optional = [FieldRule "supersedes" Nothing [] Scalar Nothing Nothing Nothing (Just (HandleReferenceRule "ADR" [] False)) Nothing Nothing] }, pathPattern = Just "decisions/*", resourceScheme = Nothing,@@ -347,7 +489,7 @@ } undocumentedField :: Text.Text -> FieldRule-undocumentedField key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, reference = Nothing, when = Nothing}+undocumentedField key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, objectFields = Nothing, reference = Nothing, path = Nothing, when = Nothing} sampleNestedProfile :: ProfileSpec sampleNestedProfile =@@ -366,13 +508,37 @@ Nothing ( Just NestedRules- { required = [NestedFieldRule "outcome" Nothing ["approved", "rejected"] Any Nothing (Just (FieldCondition "kind" ["model"]))],- recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing],- optional = [NestedFieldRule "model" Nothing [] Scalar Nothing Nothing]+ { required = [NestedFieldRule "outcome" Nothing ["approved", "rejected"] Any Nothing Nothing (Just (FieldCondition "kind" ["model"]))],+ recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing Nothing],+ optional = [NestedFieldRule "model" Nothing [] Scalar Nothing Nothing Nothing] } ) Nothing Nothing+ Nothing+ Nothing,+ -- The OKF v0.2 §10 executor, as a house convention would express+ -- it: a mapping-valued key whose members are constrained with+ -- `objectFields`, with a path policy on the member that names a+ -- file. This is the shape `okf profile show` rendered nowhere+ -- before, so it is asserted here rather than only in prose.+ FieldRule+ "executor"+ (Just "Run instructions and the receipt fields a run must return.")+ []+ Any+ Nothing+ Nothing+ ( Just+ NestedRules+ { required = [NestedFieldRule "resource" (Just "The skill a runner follows.") [] Scalar Nothing (Just (PathReferenceRule [] False)) Nothing],+ recommended = [],+ optional = [NestedFieldRule "receipt" Nothing [] List Nothing Nothing Nothing]+ }+ )+ Nothing+ Nothing+ Nothing ], recommended = [], optional = []@@ -380,6 +546,7 @@ allowUnknownTypes = True, allowUnknownFields = True, idField = Nothing,+ requireBundleVersion = Nothing, types = [] } @@ -389,6 +556,7 @@ "name: nested", "description: (none)", "okfVersion: 0.1",+ "requireBundleVersion: (none)", "allowUnknownTypes: true", "allowUnknownFields: true", "idField: (none)",@@ -398,26 +566,55 @@ " cardinality: any", " format: (none)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields:", " required:", " - outcome: (none)", " allowedValues: approved, rejected", " cardinality: any", " format: (none)",+ " path: (none)", " when: kind in [model]", " recommended:", " - notes: (none)", " allowedValues: (any)", " cardinality: scalar", " format: (none)",+ " path: (none)", " when: (none)", " optional:", " - model: (none)", " allowedValues: (any)", " cardinality: scalar", " format: (none)",+ " path: (none)", " when: (none)",+ " - executor: Run instructions and the receipt fields a run must return.",+ " allowedValues: (any)",+ " cardinality: any",+ " format: (none)",+ " reference: (none)",+ " path: (none)",+ " when: (none)",+ " objectFields:",+ " required:",+ " - resource: The skill a runner follows.",+ " allowedValues: (any)",+ " cardinality: scalar",+ " format: (none)",+ " path: external-uri-schemes([]), allow-self(false)",+ " when: (none)",+ " recommended: (none)",+ " optional:",+ " - receipt: (none)",+ " allowedValues: (any)",+ " cardinality: list",+ " format: (none)",+ " path: (none)",+ " when: (none)",+ " elementFields: (none)", "frontmatter.recommended: (none)", "frontmatter.optional: (none)" ]@@ -431,6 +628,7 @@ "name: decisions", "description: How this team records architectural decisions.", "okfVersion: 0.1",+ "requireBundleVersion: (none)", "allowUnknownTypes: false", "allowUnknownFields: true", "idField: docId",@@ -440,14 +638,18 @@ " cardinality: any", " format: (none)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", " - title: (none)", " allowedValues: (any)", " cardinality: any", " format: (none)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", "frontmatter.recommended: (none)", "frontmatter.optional:",@@ -456,7 +658,9 @@ " cardinality: scalar", " format: (none)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", "", "type: Decision Record",@@ -467,7 +671,9 @@ " cardinality: scalar", " format: document-handle(USR)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", " frontmatter.recommended:", " - reviewer: (none)",@@ -475,7 +681,9 @@ " cardinality: list", " format: (none)", " reference: local-prefix(ADR), external-uri-schemes([mori]), allow-self(false)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", " frontmatter.optional:", " - supersedes: (none)",@@ -483,7 +691,9 @@ " cardinality: scalar", " format: (none)", " reference: local-prefix(ADR), external-uri-schemes([]), allow-self(false)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", " pathPattern: decisions/*", " resourceScheme: (none)",@@ -501,6 +711,7 @@ "name: shinzui-postgresql", "description: (none)", "okfVersion: 0.1",+ "requireBundleVersion: (none)", "allowUnknownTypes: false", "allowUnknownFields: true", "idField: (none)",@@ -510,14 +721,18 @@ " cardinality: any", " format: (none)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", " - title: (none)", " allowedValues: (any)", " cardinality: any", " format: (none)", " reference: (none)",+ " path: (none)", " when: (none)",+ " objectFields: (none)", " elementFields: (none)", "frontmatter.recommended: (none)", "frontmatter.optional: (none)",@@ -557,6 +772,483 @@ case execParserPure defaultPrefs parserInfo args of Success (Options (ShowConcept opts)) -> opts == expected _ -> False++-- | A repository-relative path, resolved whether the test runs from the package+-- directory (what @cabal test@ does) or from the repository root, and 'Nothing'+-- when neither exists.+repositoryPath :: FilePath -> IO (Maybe FilePath)+repositoryPath relative = findExisting [relative, ".." </> relative]+ where+ findExisting [] = pure Nothing+ findExisting (candidate : rest) = do+ isFile <- doesFileExist candidate+ isDirectory <- doesDirectoryExist candidate+ if isFile || isDirectory then pure (Just candidate) else findExisting rest++-- | Run a test that asserts against artifacts committed to this repository —+-- @docs\/profiles@, @examples\/@, and @okf-core@'s fixtures. None of those can+-- ship inside @okf-cli@'s sdist, because cabal refuses to package a path outside+-- the package tree, so a build from the Hackage tarball (which is what nixpkgs+-- does, with tests enabled by default) has no repository tree to read. There the+-- test reports SKIP and passes rather than failing on a missing file. Inside the+-- repository every one of them runs, which is where they are meant to catch+-- drift.+withRepositoryPath :: String -> FilePath -> (FilePath -> IO Bool) -> IO Bool+withRepositoryPath name relative body = do+ resolved <- repositoryPath relative+ case resolved of+ Nothing -> skipWithoutRepository name+ Just path -> body path++-- | 'withRepositoryPath' for a test that needs two repository artifacts.+withRepositoryPath2 ::+ String -> FilePath -> FilePath -> (FilePath -> FilePath -> IO Bool) -> IO Bool+withRepositoryPath2 name firstRelative secondRelative body = do+ resolvedFirst <- repositoryPath firstRelative+ resolvedSecond <- repositoryPath secondRelative+ case (resolvedFirst, resolvedSecond) of+ (Just firstPath, Just secondPath) -> body firstPath secondPath+ _ -> skipWithoutRepository name++skipWithoutRepository :: String -> IO Bool+skipWithoutRepository name = do+ putStrLn ("SKIP " <> name <> ": requires the okf repository tree")+ pure True++-- | Every @.md@ file under a bundle, as (bundle-relative path, contents), sorted+-- so two trees compare deterministically.+readMarkdownTree :: FilePath -> IO [(FilePath, Text.Text)]+readMarkdownTree root = List.sortOn fst <$> go ""+ where+ go relative = do+ entries <- listDirectory (root </> relative)+ fmap concat . mapM (visit relative) $ entries+ visit relative entry = do+ let relativeEntry = if null relative then entry else relative </> entry+ isDirectory <- doesDirectoryExist (root </> relativeEntry)+ if isDirectory+ then go relativeEntry+ else+ if ".md" `List.isSuffixOf` entry+ then do+ contents <- Text.IO.readFile (root </> relativeEntry)+ pure [(relativeEntry, contents)]+ else pure []++-- | Options that regenerate the committed example into @destination@. Kept in+-- one place so the drift test and the strict test cannot disagree about them.+-- | The last argument is the OKF version to declare in the generated bundle's+-- root index, or 'Nothing' to declare none. The provenance flags are left unset+-- so every caller exercises the default @generated.by@.+exampleDocumentOptions ::+ FilePath ->+ FilePath ->+ Maybe Text.Text ->+ Maybe Text.Text ->+ ProfileDocumentOptions+exampleDocumentOptions descriptor destination stamp version =+ ProfileDocumentOptions+ { registryRef = Nothing,+ export = Nothing,+ profilePath = Just descriptor,+ outputPath = Just destination,+ write = True,+ timestamp = stamp,+ generatedBy = Nothing,+ generatedAt = Nothing,+ okfVersion = version+ }++-- | The committed @examples/postgresql-profile@ must be exactly what the+-- generator produces today. A failure means either the generator changed or the+-- example is stale; the message names the files so the reader knows which.+testProfileDocumentationMatchesCommittedExample :: IO Bool+testProfileDocumentationMatchesCommittedExample =+ withRepositoryPath2+ "generated documentation matches examples/postgresql-profile"+ ("docs" </> "profiles" </> "postgresql.dhall")+ ("examples" </> "postgresql-profile")+ $ \descriptor committedRoot -> do+ committed <- readMarkdownTree committedRoot+ temporaryDirectory <- getTemporaryDirectory+ scratch <- createTempDirectory temporaryDirectory "okf-cli-profile-doc-drift"+ bracket (pure scratch) removeDirectoryRecursive $ \root -> do+ let destination = root </> "regenerated"+ runCommand (Profile (ProfileDocument (exampleDocumentOptions descriptor destination Nothing (Just "0.2"))))+ regenerated <- readMarkdownTree destination+ let changed = [path | (path, content) <- committed, lookup path regenerated /= Just content]+ added = [path | (path, _) <- regenerated, path `notElem` map fst committed]+ differing = changed <> added+ unless (null differing) $+ putStrLn+ ( "examples/postgresql-profile is stale or the generator changed; differing files: "+ <> unwords differing+ <> "\nregenerate with: okf profile document --profile docs/profiles/postgresql.dhall --out examples/postgresql-profile --write --okf-version 0.2"+ )+ pure (null differing)++-- | The committed example must satisfy the meta-profile with deviations+-- enforced. An empty violation list is exactly what @--profile-enforce@ turns+-- into exit code 0, so asserting on the list is stronger than shelling out.+testProfileDocumentationConformsToMetaProfile :: IO Bool+testProfileDocumentationConformsToMetaProfile =+ withRepositoryPath2+ "examples/postgresql-profile conforms to the meta-profile"+ ("docs" </> "profiles" </> "profile-documentation.dhall")+ ("examples" </> "postgresql-profile")+ $ \metaProfilePath committedRoot -> do+ loaded <- loadProfileFile metaProfilePath+ walked <- walkBundle committedRoot+ -- Read the declaration out of the committed bundle rather than+ -- constructing it, so this also proves the committed root index really+ -- carries `okf_version: "0.2"`. Validating against the declared version+ -- is what makes the legacy-`timestamp` lint reachable: the committed+ -- example passing means it carries no retired key.+ declaredVersion <- readBundleVersion committedRoot+ case (loaded, walked, declaredVersion) of+ (Left err, _, _) -> reportFailure ("failed to load the meta-profile: " <> Text.unpack err)+ (_, Left bundleError, _) -> reportFailure ("failed to walk the committed example: " <> show bundleError)+ (_, _, Left bundleError) -> reportFailure ("failed to read the committed example's version: " <> show bundleError)+ (_, _, Right VersionUndeclared) -> reportFailure "the committed example declares no okf_version; regenerate it with --okf-version 0.2"+ (Right spec, Right concepts, Right declared) ->+ case compileProfile spec of+ Left definitionErrors -> reportFailure ("meta-profile does not compile: " <> show definitionErrors)+ Right compiled -> do+ let profileViolations = validateProfile PermissiveConformance compiled concepts+ structuralErrors = validateBundle PermissiveConformance declared (bundleInventoryOfConcepts concepts) concepts+ unless (null profileViolations) $+ putStrLn ("committed example deviates from the meta-profile: " <> show profileViolations)+ unless (null structuralErrors) $+ putStrLn ("committed example is not structurally valid: " <> show structuralErrors)+ pure (null profileViolations && null structuralErrors)+ where+ reportFailure message = putStrLn message >> pure False++-- | The shipped PostgreSQL profile requires its bundles to declare v0.2, and the+-- shipped sample bundle satisfies it.+--+-- Asserted against the shipped pair rather than a fixture, because the pair is+-- what a reader copies: a profile that demanded a declaration its own example+-- bundle lacked would teach the wrong thing. The second half proves the+-- requirement is not vacuous — strip the declaration and exactly one violation+-- appears, which is what @--profile-enforce@ turns into exit code 1.+testShippedProfileRequiresBundleVersion :: IO Bool+testShippedProfileRequiresBundleVersion =+ withRepositoryPath2+ "the shipped PostgreSQL profile requires a declared v0.2 bundle"+ ("docs" </> "profiles" </> "postgresql.dhall")+ ("examples" </> "postgresql-sample")+ $ \descriptor bundleRoot -> do+ loaded <- loadProfileFile descriptor+ declared <- readBundleVersion bundleRoot+ case (loaded, declared) of+ (Left err, _) -> reportFailure ("failed to load the PostgreSQL profile: " <> Text.unpack err)+ (_, Left bundleError) -> reportFailure ("failed to read the sample bundle's version: " <> show bundleError)+ (Right spec, Right declaration) ->+ case compileProfile spec of+ Left definitionErrors ->+ reportFailure ("the PostgreSQL profile does not compile: " <> show definitionErrors)+ Right compiled -> do+ let satisfied = validateProfileVersion declaration compiled+ withoutDeclaration = validateProfileVersion VersionUndeclared compiled+ unless (null satisfied) $+ putStrLn ("examples/postgresql-sample does not satisfy its own profile: " <> show satisfied)+ unless (length withoutDeclaration == 1) $+ putStrLn+ ( "expected exactly one violation for an undeclared bundle, got: "+ <> show withoutDeclaration+ )+ pure (null satisfied && length withoutDeclaration == 1)+ where+ reportFailure message = putStrLn message >> pure False++-- | The shipped OKF v0.2 reference profile compiles.+--+-- This is the guard that keeps a shipped descriptor honest as the descriptor+-- language grows: it is the one profile in this repository that uses every+-- primitive MasterPlan 8 added — object rules, the actor and non-negative-integer+-- formats, and both §5.2 spellings of @verified@ — so a schema change that+-- breaks it breaks it here rather than in a user's bundle.+testReferenceProfileCompiles :: IO Bool+testReferenceProfileCompiles =+ withRepositoryPath+ "the v0.2 reference profile compiles"+ ("docs" </> "profiles" </> "okf-v0-2.dhall")+ $ \descriptor -> do+ loaded <- loadProfileFile descriptor+ case loaded of+ Left err -> reportFailure ("failed to load the v0.2 reference profile: " <> Text.unpack err)+ Right spec ->+ case compileProfile spec of+ Left definitionErrors ->+ reportFailure ("the v0.2 reference profile does not compile: " <> show definitionErrors)+ Right _ -> pure True+ where+ reportFailure message = putStrLn message >> pure False++-- | The shipped reference profile accepts the shipped @examples/ddd-ordering@+-- bundle under strict authoring, with no deviations at all.+--+-- @docs\/masterplans\/7-adopt-okf-v0-2-core-semantics.md@ records that shipped+-- examples are user-facing surface that had no test behind them, and that two of+-- them had never passed @--strict@. This is that test: it asserts on the+-- violation list rather than shelling out, because an empty list is exactly what+-- @--profile-enforce@ turns into exit code 0.+testReferenceProfileAcceptsDddOrdering :: IO Bool+testReferenceProfileAcceptsDddOrdering =+ withRepositoryPath2+ "the v0.2 reference profile accepts examples/ddd-ordering"+ ("docs" </> "profiles" </> "okf-v0-2.dhall")+ ("examples" </> "ddd-ordering")+ $ \descriptor bundleRoot -> do+ loaded <- loadProfileFile descriptor+ walked <- walkBundle bundleRoot+ case (loaded, walked) of+ (Left err, _) -> reportFailure ("failed to load the v0.2 reference profile: " <> Text.unpack err)+ (_, Left bundleError) -> reportFailure ("failed to walk examples/ddd-ordering: " <> show bundleError)+ (Right spec, Right concepts) ->+ case compileProfile spec of+ Left definitionErrors ->+ reportFailure ("the v0.2 reference profile does not compile: " <> show definitionErrors)+ Right compiled -> do+ let profileViolations = validateProfile StrictAuthoring compiled concepts+ unless (null profileViolations) $+ putStrLn ("examples/ddd-ordering deviates from the v0.2 reference profile: " <> show profileViolations)+ -- A non-empty bundle, so an empty violation list cannot come from+ -- having checked nothing.+ unless (length concepts == 22) $+ putStrLn ("expected 22 concepts in examples/ddd-ordering, found " <> show (length concepts))+ pure (null profileViolations && length concepts == 22)+ where+ reportFailure message = putStrLn message >> pure False++-- | The attested computation shipped in @examples/ddd-ordering@ carries a+-- complete specification §10.2 contract and validates strictly, with both of its+-- path-valued contract fields resolving against files the bundle really holds.+--+-- @docs\/masterplans\/7-adopt-okf-v0-2-core-semantics.md@ records that shipped+-- examples are user-facing surface with no test behind them, and two of them had+-- never passed @--strict@. This asserts on the validation result rather than+-- putting a command in a document, so the example cannot rot quietly.+--+-- The @attester@ names a @.py@ file. That resolves only because the bundle+-- inventory records every file rather than only the concepts, which is the one+-- thing about this example a reader is most likely to assume is broken.+testExampleAttestedComputationValidates :: IO Bool+testExampleAttestedComputationValidates =+ withRepositoryPath+ "the examples/ddd-ordering attested computation validates"+ ("examples" </> "ddd-ordering")+ $ \bundleRoot -> do+ walked <- walkBundle bundleRoot+ inventoryResult <- walkBundleInventory bundleRoot+ case (walked, inventoryResult) of+ (Left bundleError, _) -> reportFailure ("failed to walk examples/ddd-ordering: " <> show bundleError)+ (_, Left bundleError) -> reportFailure ("failed to inventory examples/ddd-ordering: " <> show bundleError)+ (Right concepts, Right inventory) -> do+ let bundleErrors =+ validateBundle StrictAuthoring (VersionDeclared (OkfVersion 0 2)) inventory concepts+ computation =+ List.find ((== "computations/order-total") . renderConceptId . conceptIdOf) concepts+ unless (null bundleErrors) $+ putStrLn ("examples/ddd-ordering does not validate strictly: " <> show bundleErrors)+ case computation of+ Nothing -> reportFailure "examples/ddd-ordering has no computations/order-total concept"+ Just concept -> do+ let contract =+ ( conceptType concept,+ conceptRuntime concept,+ parameterName <$> conceptParameters concept,+ executorResource =<< conceptExecutor concept,+ attesterResource =<< conceptAttester concept+ )+ expected =+ ( "Attested Computation",+ Just "postgres",+ ["order_id"],+ Just "/references/skills/run-on-postgres.md",+ Just "/references/attesters/order-total.py"+ )+ unless (contract == expected) $+ putStrLn ("unexpected contract on computations/order-total: " <> show contract)+ pure (null bundleErrors && contract == expected)+ where+ reportFailure message = putStrLn message >> pure False++-- | @okf computations@ over the fixture bundle, which is built to exercise every+-- shape the report has a phrase for.+--+-- The assertion that matters most is the negative one: @metrics/revenue@ is a+-- @Metric@ in the same bundle and must not appear, which is what proves the+-- report keys on the exact @type@ string rather than on the presence of a+-- contract field. The rest cover the four absence phrases — @(no runtime)@ for+-- the §10.2-REQUIRED field missing, @(no computation)@ and @(2 computations)@+-- for the two ways §10.3's exactly-one rule breaks, and @(neither)@ for a+-- concept declaring no executor and no attester.+testComputationsReportsFixtureBundle :: IO Bool+testComputationsReportsFixtureBundle =+ assertComputationReport+ ("okf-core" </> "test" </> "fixtures" </> "attested-computation")+ [ "computations/both-computations bigquery (no parameters) (2 computations) (neither)",+ "computations/churn bigquery year inline executor",+ "computations/margin (no runtime) year (integer, required) inline (neither)",+ "computations/no-computation bigquery (no parameters) (no computation) (neither)",+ "computations/revenue bigquery year (integer, required) inline executor + attester",+ "computations/two-blocks bigquery (no parameters) (2 computations) (neither)"+ ]++-- | @okf computations@ over the shipped example, whose twenty-two concepts+-- reduce to the one row §10.5 step 1 asks for. This is the transcript+-- @docs\/user\/cli.md@ documents, pinned so the documentation cannot rot.+testComputationsReportsExampleBundle :: IO Bool+testComputationsReportsExampleBundle =+ assertComputationReport+ ("examples" </> "ddd-ordering")+ ["computations/order-total postgres order_id (uuid, required) inline executor + attester"]++assertComputationReport :: FilePath -> [Text.Text] -> IO Bool+assertComputationReport relativeBundle expected =+ withRepositoryPath ("okf computations over " <> relativeBundle) relativeBundle $ \bundleRoot -> do+ walked <- walkBundle bundleRoot+ case walked of+ Left bundleError -> do+ putStrLn ("failed to walk " <> relativeBundle <> ": " <> show bundleError)+ pure False+ Right concepts -> do+ let actual = computationReport concepts+ unless (actual == expected) $+ putStrLn+ ( "unexpected okf computations report for "+ <> relativeBundle+ <> ":\n"+ <> unlines (map Text.unpack actual)+ )+ pure (actual == expected)++-- | Generated output can satisfy strict OKF authoring once a timestamp is+-- supplied, and the meta-profile's @optional@ classification for @timestamp@+-- does not turn into a strict-mode complaint when the key is present.+testProfileDocumentationStrictWithTimestamp :: IO Bool+testProfileDocumentationStrictWithTimestamp =+ withRepositoryPath2+ "stamped generated documentation satisfies strict authoring"+ ("docs" </> "profiles" </> "postgresql.dhall")+ ("docs" </> "profiles" </> "profile-documentation.dhall")+ $ \descriptor metaProfilePath -> do+ loaded <- loadProfileFile metaProfilePath+ temporaryDirectory <- getTemporaryDirectory+ scratch <- createTempDirectory temporaryDirectory "okf-cli-profile-doc-strict"+ bracket (pure scratch) removeDirectoryRecursive $ \root -> do+ let destination = root </> "stamped"+ runCommand+ ( Profile+ ( ProfileDocument+ (exampleDocumentOptions descriptor destination (Just "2026-07-31T00:00:00Z") Nothing)+ )+ )+ walked <- walkBundle destination+ case (loaded, walked) of+ (Right spec, Right concepts) ->+ case compileProfile spec of+ Left definitionErrors -> do+ putStrLn ("meta-profile does not compile: " <> show definitionErrors)+ pure False+ Right compiled -> do+ let structuralErrors = validateBundle StrictAuthoring VersionUndeclared (bundleInventoryOfConcepts concepts) concepts+ profileViolations = validateProfile StrictAuthoring compiled concepts+ unless (null structuralErrors) $+ putStrLn ("stamped output is not strict-clean: " <> show structuralErrors)+ unless (null profileViolations) $+ putStrLn ("stamped output deviates from the meta-profile under --strict: " <> show profileViolations)+ pure (null structuralErrors && null profileViolations)+ _ -> do+ putStrLn "failed to load the meta-profile or walk the stamped output"+ pure False++-- | A descriptor that lives in @okf-core@'s fixture tree, so it resolves only+-- inside the repository — see 'withRepositoryPath'.+profileFixture :: FilePath+profileFixture =+ "okf-core" </> "test" </> "fixtures" </> "profiles" </> "optional-fields.dhall"++-- | @okf profile document --out DIR --write@ writes a real bundle, generates+-- its index files, and is idempotent: the second run leaves every byte alone.+testProfileDocumentWritesBundle :: IO Bool+testProfileDocumentWritesBundle =+ withRepositoryPath "okf profile document --write writes a bundle" profileFixture $ \descriptorPath -> do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-cli-profile-document"+ bracket (pure root) removeDirectoryRecursive $ \scratch -> do+ let destination = scratch </> "bundle"+ options =+ ProfileDocumentOptions+ { registryRef = Nothing,+ export = Nothing,+ profilePath = Just descriptorPath,+ outputPath = Just destination,+ write = True,+ timestamp = Nothing,+ generatedBy = Nothing,+ generatedAt = Nothing,+ okfVersion = Nothing+ }+ runCommand (Profile (ProfileDocument options))+ firstProfile <- Text.IO.readFile (destination </> "profile.md")+ typeWritten <- doesFileExist (destination </> "types" </> "decision-record.md")+ rootIndexWritten <- doesFileExist (destination </> "index.md")+ typeIndexWritten <- doesFileExist (destination </> "types" </> "index.md")+ -- Second run: same inputs, same destination, must change nothing.+ runCommand (Profile (ProfileDocument options))+ secondProfile <- Text.IO.readFile (destination </> "profile.md")+ walked <- walkBundle destination+ let walkedIds = case walked of+ Left _ -> []+ Right concepts -> map (renderConceptId . conceptIdOf) concepts+ -- Provenance must survive the whole write path, not merely the pure+ -- renderer: this is the frontmatter a user actually gets on disk.+ let carriesDefaultProvenance =+ Text.isInfixOf "generated:\n by: process:okf-profile-document" firstProfile+ unless carriesDefaultProvenance $+ putStrLn ("written profile.md carries no default generated.by:\n" <> Text.unpack firstProfile)+ pure+ ( typeWritten+ && rootIndexWritten+ && typeIndexWritten+ && carriesDefaultProvenance+ && firstProfile == secondProfile+ && walkedIds == ["profile", "types/decision-record"]+ )++-- | @--okf-version@ must reach the destination's root index, so a generated+-- bundle can declare the format version it targets without a second command.+testProfileDocumentDeclaresOkfVersion :: IO Bool+testProfileDocumentDeclaresOkfVersion =+ withRepositoryPath "okf profile document --okf-version declares the version" profileFixture $ \descriptorPath -> do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-cli-profile-document-version"+ bracket (pure root) removeDirectoryRecursive $ \scratch -> do+ let destination = scratch </> "bundle"+ runCommand+ ( Profile+ (ProfileDocument (exampleDocumentOptions descriptorPath destination Nothing (Just "0.2")))+ )+ declared <- readBundleVersion destination+ let expected = Right (VersionDeclared version02) `asTypeOf` declared+ version02 = case parseOkfVersion "0.2" of+ Just parsed -> parsed+ Nothing -> error "0.2 must parse as an OKF version"+ unless (declared == expected) $+ putStrLn ("generated root index declares " <> show declared <> ", expected " <> show expected)+ -- Regenerating without the flag must preserve the declaration rather than+ -- silently stripping it.+ runCommand+ ( Profile+ (ProfileDocument (exampleDocumentOptions descriptorPath destination Nothing Nothing))+ )+ preserved <- readBundleVersion destination+ unless (preserved == declared) $+ putStrLn ("regenerating without --okf-version changed the declaration to " <> show preserved)+ pure (declared == expected && preserved == declared) testLogAddWritesFile :: IO Bool testLogAddWritesFile = do