@okfit/profiles 0.8.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Canonical.js ADDED
@@ -0,0 +1,68 @@
1
+ import { body } from "./internal/body.js";
2
+
3
+ //#region src/Canonical.ts
4
+ /** Top-level keys that are bookkeeping, not authored content (spec section 4). */
5
+ const EXCLUDED_KEYS = /* @__PURE__ */ new Set([
6
+ "generated",
7
+ "verified",
8
+ "integrity",
9
+ "stale_after",
10
+ "status",
11
+ "timestamp"
12
+ ]);
13
+ /** `JSON.stringify` omits these in an object and writes `null` for them in an array. */
14
+ const isSkipped = (value) => value === void 0 || typeof value === "function" || typeof value === "symbol";
15
+ /**
16
+ * Canonical JSON: object keys sorted (`Array.prototype.toSorted`, UTF-16 code units)
17
+ * at every depth; arrays keep their order. Byte-for-byte `JSON.stringify` of the
18
+ * sorted value for every JSON-representable input, plus one extension: a `bigint`
19
+ * (what the YAML decoder yields for an integer beyond 2^53, e.g. `ticket:
20
+ * 123456789012345678901`) is written as its bare decimal literal. A literal cannot
21
+ * collide with a string (strings are quoted) and is deterministic, where
22
+ * `JSON.stringify` throws `TypeError` and would defect every caller that hashes.
23
+ */
24
+ const json = (value) => {
25
+ if (typeof value === "bigint") return value.toString();
26
+ if (Array.isArray(value)) return `[${value.map((item) => isSkipped(item) ? "null" : json(item)).join(",")}]`;
27
+ if (value !== null && typeof value === "object") {
28
+ const record = value;
29
+ if (typeof record.toJSON === "function") return json(record.toJSON());
30
+ return `{${Object.keys(record).toSorted().filter((key) => !isSkipped(record[key])).map((key) => `${JSON.stringify(key)}:${json(record[key])}`).join(",")}}`;
31
+ }
32
+ return JSON.stringify(value);
33
+ };
34
+ const stripRendersDigests = (renders) => Array.isArray(renders) ? renders.map((entry) => {
35
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return entry;
36
+ const { body: _body, body_sha256: _legacy, ...rest } = entry;
37
+ return rest;
38
+ }) : renders;
39
+ /**
40
+ * The exact string a content digest hashes: canonical JSON of the authored frontmatter
41
+ * (`raw` minus `Canonical.EXCLUDED_KEYS` and every `renders[].body`) and the
42
+ * normalised body. `raw` is the parsed mapping (YAML 1.2 Core Schema: timestamps are
43
+ * strings), so quoting, flow vs block style, key order and CRLF never change it.
44
+ */
45
+ const contentInput = (raw, source) => {
46
+ const frontmatter = Object.fromEntries(Object.entries(raw).filter(([key]) => !EXCLUDED_KEYS.has(key)).map(([key, value]) => [key, key === "renders" ? stripRendersDigests(value) : value]));
47
+ return json({
48
+ body: body(source),
49
+ frontmatter
50
+ });
51
+ };
52
+ /**
53
+ * Canonical encoding for okfit's content digest.
54
+ *
55
+ * @public
56
+ */
57
+ var Canonical = class {
58
+ constructor() {}
59
+ /** Top-level frontmatter keys excluded from the content digest. */
60
+ static EXCLUDED_KEYS = EXCLUDED_KEYS;
61
+ /** Canonical JSON: sorted keys at every depth, array order kept; a `bigint` is its bare decimal literal. */
62
+ static json = json;
63
+ /** The exact string a content digest hashes. */
64
+ static contentInput = contentInput;
65
+ };
66
+
67
+ //#endregion
68
+ export { Canonical };
package/Derivation.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { body } from "./internal/body.js";
2
+ import { Canonical } from "./Canonical.js";
1
3
  import { GitHistory } from "./GitHistory.js";
2
4
  import { Actor, OkfitConfig } from "@okfit/core";
3
5
  import { Crypto, DateTime, Duration, Effect, FileSystem, Option, Path, Schema } from "effect";
4
6
  import { Git } from "@effected/git";
5
- import { FrontmatterSource } from "@effected/markdown";
6
7
  import * as Hex from "effect/encoding/Hex";
7
8
 
8
9
  //#region src/Derivation.ts
@@ -35,15 +36,40 @@ var AgentActorUnconfiguredError = class extends Schema.TaggedError()("AgentActor
35
36
  return "writer is agent but actors.agent is not configured";
36
37
  }
37
38
  };
39
+ /**
40
+ * `git config okfit.actor` is set but is not a `human:<id>` actor without `@`
41
+ * or glob characters. The value stays on the person's machine; okfit never
42
+ * writes it anywhere.
43
+ *
44
+ * @public
45
+ */
46
+ var ActorConfigInvalidError = class extends Schema.TaggedError()("ActorConfigInvalidError", { value: Schema.String }) {
47
+ get message() {
48
+ return `git config okfit.actor is "${this.value}"; it must be a human:<id> actor with no "@" and no * or ? (for example human:ada)`;
49
+ }
50
+ };
51
+ /**
52
+ * The resolved human is not on a non-empty `[actors].humans` allow-list, so it
53
+ * may not verify or revoke (spec section 8).
54
+ *
55
+ * @public
56
+ */
57
+ var HumanActorNotAllowedError = class extends Schema.TaggedError()("HumanActorNotAllowedError", { actor: Schema.String }) {
58
+ get message() {
59
+ return `${this.actor} is not in [actors].humans, so it cannot verify or revoke; set your id with git config --global okfit.actor human:<id> to a listed id, or add ${this.actor} to [actors].humans`;
60
+ }
61
+ };
38
62
  const HUMAN_PREFIX = "human:";
39
63
  const FALLBACK_STALE_AFTER = Duration.days(90);
40
64
  const decodeActor = Schema.decodeSync(Actor);
41
- const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
42
- const body = (text) => normalise(FrontmatterSource.split(text).body);
43
- const bodyDigest = (text) => Effect.gen(function* () {
44
- const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(body(text)));
65
+ const digestFull = (text) => Effect.gen(function* () {
66
+ const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(text));
45
67
  return Hex.encode(bytes);
46
68
  });
69
+ /** 16-hex okfit digest (spec section 3): the first 64 bits of SHA-256. */
70
+ const digest = (text) => Effect.map(digestFull(text), (hex) => hex.slice(0, 16));
71
+ const bodyDigest = (source) => digest(body(source));
72
+ const contentDigest = (raw, source) => digest(Canonical.contentInput(raw, source));
47
73
  /** The text before the first `@` (the whole value when there is none), or undefined when empty or containing whitespace. */
48
74
  const localPartOf = (email) => {
49
75
  if (email === void 0) return void 0;
@@ -75,8 +101,8 @@ const humanActorId = (identity, humans) => {
75
101
  const slug = slugOf(identity.name);
76
102
  const candidates = [local, slug].filter((candidate) => candidate !== void 0).map((candidate) => candidate.toLowerCase());
77
103
  for (const entry of humans) {
78
- if (!entry.startsWith(HUMAN_PREFIX)) continue;
79
- if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(entry);
104
+ if (!entry.startsWith(HUMAN_PREFIX) || Actor.isPattern(entry)) continue;
105
+ if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(decodeActor(entry));
80
106
  }
81
107
  if (local !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${local}`));
82
108
  if (slug !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${slug}`));
@@ -104,7 +130,7 @@ const uncommitted = (reason) => ({
104
130
  *
105
131
  * @public
106
132
  */
107
- var Derivation = class {
133
+ var Derivation = class Derivation {
108
134
  constructor() {}
109
135
  /**
110
136
  * `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
@@ -112,12 +138,33 @@ var Derivation = class {
112
138
  */
113
139
  static body = body;
114
140
  /**
115
- * Lowercase 64-char hex sha256 of `Derivation.body(text)`, through `effect`'s platform-agnostic
116
- * `Crypto` service (issue #19). Same normalization as `body`, so a frontmatter-only edit or a
117
- * `core.autocrlf` worktree never changes the digest.
141
+ * Full lowercase 64-char hex sha256 of a string. Tests only.
142
+ *
143
+ * @internal
144
+ */
145
+ static digestFull = digestFull;
146
+ /**
147
+ * The okfit digest of a string: the first 16 lowercase hex characters of its SHA-256, through
148
+ * `effect`'s platform-agnostic `Crypto` service.
149
+ */
150
+ static digest = digest;
151
+ /**
152
+ * 16-hex digest of `Derivation.body(source)` (issue #19). Same normalization as `body`, so a
153
+ * frontmatter-only edit or a `core.autocrlf` worktree never changes the digest.
118
154
  */
119
155
  static bodyDigest = bodyDigest;
120
156
  /**
157
+ * The body digest recorded for a generated concept: `integrity.generated.body`, else the legacy
158
+ * `generated.body_sha256` cut to its first 16 hex characters. `undefined` when neither is present.
159
+ * Pure; every "is the stamped body still current" comparison reads through this.
160
+ */
161
+ static recordedBody = (concept) => concept.frontmatter.integrity?.generated?.body ?? concept.frontmatter.generated?.body_sha256?.slice(0, 16);
162
+ /**
163
+ * 16-hex digest of the canonical content input (`Canonical.contentInput`): authored frontmatter
164
+ * plus the normalised body, insensitive to style, key order and CRLF.
165
+ */
166
+ static contentDigest = contentDigest;
167
+ /**
121
168
  * Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
122
169
  * 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
123
170
  * submodules and worktrees work because the question is asked from the file's directory (P-39).
@@ -172,14 +219,15 @@ var Derivation = class {
172
219
  });
173
220
  /**
174
221
  * P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
175
- * and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
176
- * candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
222
+ * and the `user.name` slug. Order, first hit wins: (1) a literal `human:` entry of `humans` (glob entries,
223
+ * those with `*` or `?`, are skipped) whose id equals a candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
177
224
  * The result is branded through core's `Actor` codec and always passes it.
178
225
  */
179
226
  static humanActorId = humanActorId;
180
227
  /**
181
228
  * Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
182
- * (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
229
+ * (P-17). `"human"`: first `Git.configGet(cwd, "okfit.actor")`, which wins when set and must be a
230
+ * `human:<id>` with no `@` or glob ({@link ActorConfigInvalidError} otherwise), returned in the spelling of an exact `[actors].humans` entry that equals it case-insensitively (P-14); else `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
183
231
  * scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
184
232
  * Answers only "who is writing now" (P-40).
185
233
  */
@@ -190,6 +238,13 @@ var Derivation = class {
190
238
  return agent;
191
239
  }
192
240
  const git = yield* Git;
241
+ const chosen = Option.getOrUndefined(yield* git.configGet(options.cwd, "okfit.actor"));
242
+ if (chosen !== void 0) {
243
+ if (!(Schema.is(Actor)(chosen) && Actor.isHuman(chosen) && !chosen.includes("@") && !Actor.isPattern(chosen))) return yield* new ActorConfigInvalidError({ value: chosen });
244
+ const lower = chosen.toLowerCase();
245
+ const spelled = (options.config.actors?.humans ?? []).find((entry) => !Actor.isPattern(entry) && entry.toLowerCase() === lower);
246
+ return spelled === void 0 ? chosen : decodeActor(spelled);
247
+ }
193
248
  const name = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.name"));
194
249
  const email = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.email"));
195
250
  const identity = {
@@ -204,6 +259,22 @@ var Derivation = class {
204
259
  return resolved.value;
205
260
  });
206
261
  /**
262
+ * The human allowed to verify now: {@link Derivation.generatedBy} for a human
263
+ * writer, then the `[actors].humans` allow-list (`Actor.allowed`): empty or
264
+ * absent allows anyone, otherwise the actor must match an entry or this fails
265
+ * {@link HumanActorNotAllowedError}. `okfit verify`, `--revoke` and the LSP
266
+ * verify actions all resolve through this.
267
+ */
268
+ static verifier = Effect.fn("Derivation.verifier")(function* (options) {
269
+ const actor = yield* Derivation.generatedBy({
270
+ writer: "human",
271
+ cwd: options.cwd,
272
+ config: options.config
273
+ });
274
+ if (!Actor.allowed(actor, options.config.actors?.humans ?? [])) return yield* new HumanActorNotAllowedError({ actor });
275
+ return actor;
276
+ });
277
+ /**
207
278
  * P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
208
279
  * 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
209
280
  */
@@ -211,4 +282,4 @@ var Derivation = class {
211
282
  };
212
283
 
213
284
  //#endregion
214
- export { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError };
285
+ export { ActorConfigInvalidError, AgentActorUnconfiguredError, Derivation, HumanActorNotAllowedError, HumanActorUnresolvedError };
package/Provenance.js CHANGED
@@ -12,17 +12,20 @@ const encodeAt = Schema.encodeSync(Timestamp);
12
12
  * like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
13
13
  * are unchanged.
14
14
  *
15
- * Two tiers per concept (issue #19, body-digest design): when
16
- * `generated.body_sha256` is present, drift is decided ENTIRELY by comparing it to
15
+ * Two tiers per concept (issue #19, body-digest design): when a body digest
16
+ * is recorded (`integrity.generated.body`, else the 16-hex prefix of a legacy
17
+ * `generated.body_sha256`, via `Derivation.recordedBody`), drift is decided
18
+ * ENTIRELY by comparing it to
17
19
  * `Derivation.bodyDigest` of the concept's current source -- no git call at
18
20
  * all, so this works for a dirty worktree too (that is the point: an edited
19
21
  * body is caught immediately, before it is even committed) and survives a
20
22
  * squash/rebase merge that only rewrites commit dates, since the body
21
- * itself, and therefore its digest, never changed. When `body_sha256` is
22
- * absent (an un-migrated concept), this falls back to the original
23
+ * itself, and therefore its digest, never changed. When no digest is recorded
24
+ * at all (a concept `okfit sync` has never stamped), this falls back to the original
23
25
  * git-derived date comparison against `Derivation.generatedAt`, preserving
24
26
  * `--skip-provenance`'s meaning for bundles that have not run `okfit sync`
25
- * since this field was introduced.
27
+ * since the digest was introduced. The drift message names the field the
28
+ * recorded value came from, so a legacy concept reads `generated.body_sha256`.
26
29
  *
27
30
  * The tier-2 fallback returns `[]` the moment a `NotARepositoryError`
28
31
  * surfaces: a bundle outside git has nothing to derive for any concept still
@@ -40,8 +43,8 @@ const encodeAt = Schema.encodeSync(Timestamp);
40
43
  * calling at all; the two agree by construction (both skip the walk).
41
44
  *
42
45
  * A third, optional options argument carries `skipGitTier`: when `true`, a
43
- * concept that falls through to tier 2 (no `generated.body_sha256`
44
- * recorded) is skipped entirely -- no `Derivation.generatedAt` call, no
46
+ * concept that falls through to tier 2 (no body digest recorded) is
47
+ * skipped entirely -- no `Derivation.generatedAt` call, no
45
48
  * git spawn at all -- while tier 1 still runs, unaffected, for every
46
49
  * concept that DOES carry a digest. This is a runtime skip, not a
47
50
  * type-level narrowing: the R channel still lists `Git | GitHistory` even
@@ -72,11 +75,13 @@ var Provenance = class {
72
75
  };
73
76
  for (const [, concept] of bundle.concepts) {
74
77
  if (concept.frontmatter.generated === void 0) continue;
75
- const recordedDigest = concept.frontmatter.generated.body_sha256;
78
+ const recordedDigest = Derivation.recordedBody(concept);
76
79
  if (recordedDigest !== void 0) {
77
80
  const currentDigest = yield* Derivation.bodyDigest(concept.document.source);
78
81
  if (currentDigest === recordedDigest) continue;
79
- pushDrift(concept.path, `the body has changed since generated.at was last stamped: generated.body_sha256 is ${recordedDigest}, the current body hashes to ${currentDigest}`, DiagnosticRange.forFrontmatterPath(concept.document, ["generated", "at"]));
82
+ const legacy = concept.frontmatter.generated.body_sha256;
83
+ const recordedAs = concept.frontmatter.integrity?.generated?.body === void 0 && legacy !== void 0 ? `generated.body_sha256 is ${legacy}` : `integrity.generated.body is ${recordedDigest}`;
84
+ pushDrift(concept.path, `the body has changed since generated.at was last stamped: ${recordedAs}, the current body hashes to ${currentDigest}`, DiagnosticRange.forFrontmatterPath(concept.document, ["generated", "at"]));
80
85
  continue;
81
86
  }
82
87
  if (skipGitTier) continue;
@@ -0,0 +1,135 @@
1
+ import { Derivation } from "./Derivation.js";
2
+ import { ConceptId, Diagnostic, DiagnosticRange, OkfitConfig } from "@okfit/core";
3
+ import { Effect, Option } from "effect";
4
+
5
+ //#region src/Publications.ts
6
+ /**
7
+ * The concept type name of a documentation surface.
8
+ *
9
+ * @public
10
+ */
11
+ const SURFACE_TYPE = "Surface";
12
+ /**
13
+ * The concept type name of a published page rendered from bundle concepts.
14
+ *
15
+ * @public
16
+ */
17
+ const PUBLICATION_TYPE = "Publication";
18
+ const resolveRef = (from, raw) => {
19
+ const segments = from.split("/").slice(0, -1);
20
+ for (const part of raw.replace(/\.md$/u, "").split("/")) {
21
+ if (part === "" || part === ".") continue;
22
+ if (part === "..") {
23
+ if (segments.length === 0) return "";
24
+ segments.pop();
25
+ continue;
26
+ }
27
+ segments.push(part);
28
+ }
29
+ return segments.join("/");
30
+ };
31
+ const rendersOf = (concept) => {
32
+ const value = concept.frontmatter.raw.renders;
33
+ if (!Array.isArray(value)) return void 0;
34
+ const out = [];
35
+ for (const [index, entry] of value.entries()) {
36
+ if (typeof entry !== "object" || entry === null || typeof entry.path !== "string") return void 0;
37
+ const current = typeof entry.body === "string" && entry.body !== "" ? entry.body : void 0;
38
+ const legacy = typeof entry.body_sha256 === "string" && entry.body_sha256 !== "" ? entry.body_sha256.slice(0, 16) : void 0;
39
+ const digest = current ?? legacy;
40
+ out.push(digest === void 0 ? {
41
+ index,
42
+ path: entry.path
43
+ } : {
44
+ index,
45
+ path: entry.path,
46
+ body: digest
47
+ });
48
+ }
49
+ return out;
50
+ };
51
+ const compare = (a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
52
+ /**
53
+ * A profiles-owned lint over Publication concepts: `publication-orphan` when a
54
+ * `surface` or `renders[].path` does not resolve to a bundle concept (or
55
+ * `renders` is malformed), and `publication-drift` when a rendered source's
56
+ * body digest no longer matches the stamped `renders[].body`. Total over
57
+ * severity: returns `[]` before any digest work when both codes are `"off"`.
58
+ *
59
+ * @public
60
+ */
61
+ var Publications = class {
62
+ constructor() {}
63
+ /** Resolves a raw reference against a bundle-relative concept path; `""` when it escapes the bundle. */
64
+ static resolveRef = resolveRef;
65
+ /** The `renders` entries of a Publication, or `undefined` when malformed. */
66
+ static rendersOf = rendersOf;
67
+ /** Lints every Publication in `bundle`; diagnostics are sorted by file then code. */
68
+ static lint = Effect.fn("Publications.lint")(function* (bundle, config) {
69
+ const driftSeverity = OkfitConfig.severityFor(config, "publication-drift");
70
+ const orphanSeverity = OkfitConfig.severityFor(config, "publication-orphan");
71
+ if (driftSeverity === "off" && orphanSeverity === "off") return [];
72
+ const diagnostics = [];
73
+ const push = (concept, code, severity, message, path) => {
74
+ const range = DiagnosticRange.forFrontmatterPath(concept.document, path);
75
+ diagnostics.push(Diagnostic.make({
76
+ file: concept.path,
77
+ code,
78
+ severity,
79
+ message,
80
+ ...range === void 0 ? {} : { range }
81
+ }));
82
+ };
83
+ for (const [id, concept] of bundle.concepts) {
84
+ if (concept.frontmatter.type !== "Publication") continue;
85
+ const resource = String(concept.frontmatter.raw.resource ?? concept.path);
86
+ if (orphanSeverity !== "off") {
87
+ const surface = concept.frontmatter.raw.surface;
88
+ if (surface !== void 0) {
89
+ const target = typeof surface === "string" ? Option.match(ConceptId.normalize(resolveRef(concept.path, surface)), {
90
+ onNone: () => void 0,
91
+ onSome: (key) => bundle.concepts.get(key)
92
+ }) : void 0;
93
+ if (target === void 0) push(concept, "publication-orphan", orphanSeverity, `surface ${JSON.stringify(surface)} does not resolve to a concept in the bundle`, ["surface"]);
94
+ else if (target.frontmatter.type !== "Surface") push(concept, "publication-orphan", orphanSeverity, `surface ${JSON.stringify(surface)} resolves to a concept of type ${JSON.stringify(target.frontmatter.type)}, not ${SURFACE_TYPE}`, ["surface"]);
95
+ }
96
+ }
97
+ const renders = rendersOf(concept);
98
+ if (renders === void 0) {
99
+ if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, "renders must be a list of { path, body } entries", ["renders"]);
100
+ continue;
101
+ }
102
+ if (renders.length === 0) {
103
+ if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, "renders must name at least one source", ["renders"]);
104
+ continue;
105
+ }
106
+ const stale = [];
107
+ for (const entry of renders) {
108
+ const sourceId = resolveRef(concept.path, entry.path);
109
+ const source = Option.match(ConceptId.normalize(sourceId), {
110
+ onNone: () => void 0,
111
+ onSome: (key) => bundle.concepts.get(key)
112
+ });
113
+ if (source === void 0) {
114
+ if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, `renders[${entry.index}].path ${JSON.stringify(entry.path)} does not resolve to a concept in the bundle`, [
115
+ "renders",
116
+ entry.index,
117
+ "path"
118
+ ]);
119
+ continue;
120
+ }
121
+ if (driftSeverity === "off") continue;
122
+ if (entry.body === void 0) {
123
+ stale.push(`${sourceId} (never stamped)`);
124
+ continue;
125
+ }
126
+ if ((yield* Derivation.bodyDigest(source.document.source)) !== entry.body) stale.push(`${sourceId} (changed)`);
127
+ }
128
+ if (stale.length > 0 && driftSeverity !== "off") push(concept, "publication-drift", driftSeverity, `Rendered page ${resource} is stale against: ${stale.join(", ")}; re-render it, then run okfit sync --publication ${id}`, ["renders"]);
129
+ }
130
+ return diagnostics.sort(compare);
131
+ });
132
+ };
133
+
134
+ //#endregion
135
+ export { PUBLICATION_TYPE, Publications, SURFACE_TYPE };
package/README.md CHANGED
@@ -49,7 +49,7 @@ kind = "path"
49
49
 
50
50
  [types.Decision]
51
51
  description = "A choice made, the alternatives rejected, and why."
52
- guidance = "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it."
52
+ guidance = "Edit a Decision when the choice still stands and its text has drifted or is incomplete; the edit voids its verification until a human verifies it again. Deprecate it and write a successor that names it in supersedes only when the choice itself changes and its history matters."
53
53
  require_verified = true
54
54
 
55
55
  [types.Decision.fields.supersedes]
@@ -163,6 +163,54 @@ description = "The ISO 8601 date the failure happened or was first observed."
163
163
  description = "A path relative to this concept file to the test, check, or config that now stops the failure recurring."
164
164
  kind = "path"
165
165
 
166
+ [types.Surface]
167
+ description = "A documentation surface: where one kind of published docs lives, who reads it, and how it is written."
168
+ guidance = "Write the body as instructions to the author of a page on this surface: tone, structure, required and forbidden sections, and what belongs on another surface instead. Keep one Surface per audience and location, and point links_to at the Surface this one routes readers to."
169
+ required = ["kind", "audience", "resource"]
170
+
171
+ [types.Surface.fields.kind]
172
+ description = "How the surface is published."
173
+
174
+ [types.Surface.fields.kind.values]
175
+ site = "A built website workspace, such as an RSPress site, published at a URL."
176
+ repo = "Markdown read in place on the forge, such as a docs/ directory on GitHub."
177
+ readme = "README files: the repository root's, each package's, or both."
178
+
179
+ [types.Surface.fields.audience]
180
+ description = "Who the surface is written for."
181
+
182
+ [types.Surface.fields.audience.values]
183
+ users = "People installing or calling what this repository ships."
184
+ contributors = "People changing this repository: setup, workflow, and internals."
185
+ agents = "Coding agents working in this repository."
186
+
187
+ [types.Surface.fields.resource]
188
+ description = "A path relative to this concept file to the surface root, or a glob pattern when the surface spans many directories, such as every package directory."
189
+ kind = "path"
190
+
191
+ [types.Surface.fields.url]
192
+ description = "Where a site surface is published, as an absolute URL."
193
+
194
+ [types.Surface.fields.links_to]
195
+ description = "The Surface this one routes readers to, such as a one-page README pointing at the site."
196
+ kind = "path"
197
+
198
+ [types.Publication]
199
+ description = "A published page that restates facts this bundle owns, and the concepts it was rendered from."
200
+ guidance = "Create one only for a page whose content comes from bundle concepts; the bundle is the source of truth and the page is re-rendered when a source changes. Use the body for rendering notes: what is audience-specific, what is deliberately left out, and what a re-render must keep."
201
+ required = ["resource", "surface", "renders"]
202
+
203
+ [types.Publication.fields.resource]
204
+ description = "A path relative to this concept file to the published page."
205
+ kind = "path"
206
+
207
+ [types.Publication.fields.surface]
208
+ description = "The Surface concept this page is published on."
209
+ kind = "path"
210
+
211
+ [types.Publication.fields.renders]
212
+ description = "The source concepts as a list of entries that each hold a path and a body digest; okfit sync --publication stamps each digest."
213
+
166
214
  [tags.architecture]
167
215
  description = "Concerns the shape of the system rather than one module."
168
216
 
@@ -242,8 +290,8 @@ Each directory carries its own `index.md`. What `okfit init` writes into these f
242
290
  - **`generated.at` is the author date, not the stamp date (P-4).** Walking a path's git history newest-first, the commit that changed the body is the one whose blob first differs from its predecessor's; that commit's *author* date (`%aI`) is `generated.at`. Its committer date, author name, and author email are carried on the result for callers that want a history-based policy later, but `generated.at` itself is never the date of a later frontmatter-only stamp.
243
291
  - **The path log is `@effected/git`'s `Git.log(cwd, { paths: [path], follow: true, firstParentDiffMerges: true })` (P-5, amended P-46, decision 56).** `firstParentDiffMerges` surfaces a conflict-resolving merge whose blob differs from both parents (probed on git 2.54.0), which plain `--follow` can omit and which would otherwise attribute that body change to the wrong commit. `Git.log`'s unconditional `-z` makes `--name-only` emit paths raw — no C-style quoting — so no `core.quotePath` override is needed and a path containing a space, a quote, or a newline round-trips verbatim. The dirty check compares the worktree body against `Git.show(root, "HEAD", path)`, never against the newest log entry. `--follow`'s rename detection can itself stop at a commit that renames a file and rewrites its content enough that git no longer treats it as a continuation of the same history; when that happens, `generated.at` answers that rename commit rather than an older one.
244
292
  - **Uncommitted is the caller's to interpret (P-10).** `Derivation.generatedAt` reports `{ _tag: "uncommitted", reason: "untracked" | "dirty" | "unborn" }` and never substitutes `now`. The recommended policy, followed by the snippet below: omit `generated.at` entirely until the body is committed.
245
- - **The body digest answers "has the body changed", where dates cannot (issue #19).** `Derivation.bodyDigest(text)` is a sha256, hex-encoded, over the same normalised body `Derivation.body` produces (CRLF and lone CR to LF, trailing whitespace at end of text removed), so a `core.autocrlf` worktree and a trailing-newline change hash identically and a frontmatter-only edit does not move the digest at all. It is computed through effect's own `Crypto` service rather than `node:crypto`, so it carries `Crypto.Crypto` in its R channel; `NodeServices.layer` already supplies it wherever this package is used. `okfit sync` records it as `generated.body_sha256` — okfit's own extension key, not an OKF v0.2 field — and `Provenance.lint` compares it. Git cannot make this judgement on its own: the stamp lives in the same blob as the body, so a squash merge (one new commit carrying both) is indistinguishable by history from a body edited without a re-stamp.
246
- - **Human actor resolution (P-13).** Given git identity `{ name?, email? }` and `config.actors.humans`, the first hit wins: (1) a `human:<id>` entry whose id matches the email's local part or the name's slug, case-insensitively (config spelling returned); (2) `human:<local part>`; (3) `human:<slug of name>`; (4) unresolved. The slug lowercases `name`, replaces runs of whitespace and characters outside `[A-Za-z0-9._-]` with `-`, and trims leading and trailing `-`.
293
+ - **The body digest answers "has the body changed", where dates cannot (issue #19).** `Derivation.bodyDigest(text)` is a 16-hex SHA-256 prefix over the same normalised body `Derivation.body` produces (CRLF and lone CR to LF, trailing whitespace at end of text removed), so a `core.autocrlf` worktree and a trailing-newline change hash identically and a frontmatter-only edit does not move the digest at all. It is computed through effect's own `Crypto` service rather than `node:crypto`, so it carries `Crypto.Crypto` in its R channel; `NodeServices.layer` already supplies it wherever this package is used. `okfit sync` records it as `integrity.generated.body` — okfit's own extension key, not an OKF v0.2 field — and `Provenance.lint` compares it. Git cannot make this judgement on its own: the stamp lives in the same blob as the body, so a squash merge (one new commit carrying both) is indistinguishable by history from a body edited without a re-stamp.
294
+ - **Human actor resolution (P-13).** Given git identity `{ name?, email? }` and `config.actors.humans`, the first hit wins: (1) `git config okfit.actor`, which must be a `human:<id>` with no `@` and no `*` or `?` (`ActorConfigInvalidError` otherwise); (2) a literal `human:<id>` entry (glob entries are skipped) whose id matches the email's local part or the name's slug, case-insensitively (config spelling returned); (3) `human:<local part>`; (4) `human:<slug of name>`; (5) unresolved (`HumanActorUnresolvedError`). `Derivation.verifier` runs that resolution and then checks the `[actors].humans` allow-list with core's `Actor.allowed`: an empty or absent list allows anyone, a non-empty one fails with `HumanActorNotAllowedError`, which is what `okfit verify`, `--revoke` and the language server's verify actions surface. okfit never stores an email; the trust signal reports `actor-not-allowed` (a `DriftReason`) for a verifier outside the list. The slug lowercases `name`, replaces runs of whitespace and characters outside `[A-Za-z0-9._-]` with `-`, and trims leading and trailing `-`.
247
295
  - **Identity is read at merged config scope (P-15).** `Derivation.generatedBy` calls `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` with no `scope` option. Environment overrides (`GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `EMAIL`, `user.useConfigOnly`) are out of scope for phase 1.
248
296
  - **`stale_after` is pure addition (P-19).** `Derivation.staleAfter(from, config)` is `from + (config.lifecycle?.default_stale_after ?? OkfitConfig.DEFAULTS.lifecycle.default_stale_after)`, falling back to 90 days. `from` should be `generated.at` when the provenance is `committed`, otherwise the caller's own `now`; re-stamp whenever `generated.at` changes.
249
297
 
@@ -265,7 +313,7 @@ Frontmatter serialisation, the uncommitted policy's enforcement, `now`, layer co
265
313
  lint (`lint.generated_at_drift`, config default `"warn"`), and it runs in
266
314
  two tiers (issue #19).
267
315
 
268
- Tier 1 applies to a concept that records `generated.body_sha256`: the
316
+ Tier 1 applies to a concept that records `integrity.generated.body`: the
269
317
  recorded digest is compared against
270
318
  `Derivation.bodyDigest(concept.document.source)`, a pure comparison over
271
319
  text already in memory with no git call and no filesystem read. Drift here
@@ -1,3 +1,4 @@
1
+ import { DOCS_PRESETS } from "./presets/index.js";
1
2
  import { DiagnosticRange } from "@okfit/core";
2
3
 
3
4
  //#region src/SoftwareProject.ts
@@ -57,7 +58,7 @@ const config = {
57
58
  },
58
59
  Decision: {
59
60
  description: "A choice made, the alternatives rejected, and why.",
60
- guidance: "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it.",
61
+ guidance: "Edit a Decision when the choice still stands and its text has drifted or is incomplete; the edit voids its verification until a human verifies it again. Deprecate it and write a successor that names it in supersedes only when the choice itself changes and its history matters.",
61
62
  require_verified: true,
62
63
  fields: { supersedes: {
63
64
  description: "The Decision this one replaces.",
@@ -170,6 +171,62 @@ const config = {
170
171
  kind: "path"
171
172
  }
172
173
  }
174
+ },
175
+ Surface: {
176
+ description: "A documentation surface: where one kind of published docs lives, who reads it, and how it is written.",
177
+ guidance: "Write the body as instructions to the author of a page on this surface: tone, structure, required and forbidden sections, and what belongs on another surface instead. Keep one Surface per audience and location, and point links_to at the Surface this one routes readers to.",
178
+ required: [
179
+ "kind",
180
+ "audience",
181
+ "resource"
182
+ ],
183
+ fields: {
184
+ kind: {
185
+ description: "How the surface is published.",
186
+ values: {
187
+ site: "A built website workspace, such as an RSPress site, published at a URL.",
188
+ repo: "Markdown read in place on the forge, such as a docs/ directory on GitHub.",
189
+ readme: "README files: the repository root's, each package's, or both."
190
+ }
191
+ },
192
+ audience: {
193
+ description: "Who the surface is written for.",
194
+ values: {
195
+ users: "People installing or calling what this repository ships.",
196
+ contributors: "People changing this repository: setup, workflow, and internals.",
197
+ agents: "Coding agents working in this repository."
198
+ }
199
+ },
200
+ resource: {
201
+ description: "A path relative to this concept file to the surface root, or a glob pattern when the surface spans many directories, such as every package directory.",
202
+ kind: "path"
203
+ },
204
+ url: { description: "Where a site surface is published, as an absolute URL." },
205
+ links_to: {
206
+ description: "The Surface this one routes readers to, such as a one-page README pointing at the site.",
207
+ kind: "path"
208
+ }
209
+ }
210
+ },
211
+ Publication: {
212
+ description: "A published page that restates facts this bundle owns, and the concepts it was rendered from.",
213
+ guidance: "Create one only for a page whose content comes from bundle concepts; the bundle is the source of truth and the page is re-rendered when a source changes. Use the body for rendering notes: what is audience-specific, what is deliberately left out, and what a re-render must keep.",
214
+ required: [
215
+ "resource",
216
+ "surface",
217
+ "renders"
218
+ ],
219
+ fields: {
220
+ resource: {
221
+ description: "A path relative to this concept file to the published page.",
222
+ kind: "path"
223
+ },
224
+ surface: {
225
+ description: "The Surface concept this page is published on.",
226
+ kind: "path"
227
+ },
228
+ renders: { description: "The source concepts as a list of entries that each hold a path and a body digest; okfit sync --publication stamps each digest." }
229
+ }
173
230
  }
174
231
  },
175
232
  tags: {
@@ -256,6 +313,14 @@ const layout = {
256
313
  {
257
314
  directory: "incidents",
258
315
  type: "Incident"
316
+ },
317
+ {
318
+ directory: "surfaces",
319
+ type: "Surface"
320
+ },
321
+ {
322
+ directory: "publications",
323
+ type: "Publication"
259
324
  }
260
325
  ]
261
326
  };
@@ -306,6 +371,7 @@ const softwareProject = {
306
371
  name: "software-project",
307
372
  config,
308
373
  layout,
374
+ docsPresets: DOCS_PRESETS,
309
375
  check
310
376
  };
311
377