@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 +68 -0
- package/Derivation.js +86 -15
- package/Provenance.js +14 -9
- package/Publications.js +135 -0
- package/README.md +52 -4
- package/SoftwareProject.js +67 -1
- package/Trust.js +156 -0
- package/index.d.ts +272 -18
- package/index.js +5 -2
- package/internal/body.js +13 -0
- package/package.json +4 -4
- package/presets/index.js +51 -0
- package/presets/surfaces.js +318 -0
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
|
|
42
|
-
const
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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`
|
|
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, "
|
|
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.
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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
|
|
44
|
-
*
|
|
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
|
|
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
|
-
|
|
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;
|
package/Publications.js
ADDED
|
@@ -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 = "
|
|
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
|
|
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); (
|
|
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.
|
|
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
|
package/SoftwareProject.js
CHANGED
|
@@ -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: "
|
|
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
|
|