@okfit/profiles 0.9.0 → 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 +8 -6
- package/README.md +4 -4
- package/SoftwareProject.js +1 -1
- package/Trust.js +156 -0
- package/index.d.ts +196 -20
- package/index.js +4 -2
- package/internal/body.js +13 -0
- package/package.json +4 -4
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
CHANGED
|
@@ -34,14 +34,16 @@ const rendersOf = (concept) => {
|
|
|
34
34
|
const out = [];
|
|
35
35
|
for (const [index, entry] of value.entries()) {
|
|
36
36
|
if (typeof entry !== "object" || entry === null || typeof entry.path !== "string") return void 0;
|
|
37
|
-
const
|
|
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;
|
|
38
40
|
out.push(digest === void 0 ? {
|
|
39
41
|
index,
|
|
40
42
|
path: entry.path
|
|
41
43
|
} : {
|
|
42
44
|
index,
|
|
43
45
|
path: entry.path,
|
|
44
|
-
|
|
46
|
+
body: digest
|
|
45
47
|
});
|
|
46
48
|
}
|
|
47
49
|
return out;
|
|
@@ -51,7 +53,7 @@ const compare = (a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.code <
|
|
|
51
53
|
* A profiles-owned lint over Publication concepts: `publication-orphan` when a
|
|
52
54
|
* `surface` or `renders[].path` does not resolve to a bundle concept (or
|
|
53
55
|
* `renders` is malformed), and `publication-drift` when a rendered source's
|
|
54
|
-
* body digest no longer matches the stamped `
|
|
56
|
+
* body digest no longer matches the stamped `renders[].body`. Total over
|
|
55
57
|
* severity: returns `[]` before any digest work when both codes are `"off"`.
|
|
56
58
|
*
|
|
57
59
|
* @public
|
|
@@ -94,7 +96,7 @@ var Publications = class {
|
|
|
94
96
|
}
|
|
95
97
|
const renders = rendersOf(concept);
|
|
96
98
|
if (renders === void 0) {
|
|
97
|
-
if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, "renders must be a list of { path,
|
|
99
|
+
if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, "renders must be a list of { path, body } entries", ["renders"]);
|
|
98
100
|
continue;
|
|
99
101
|
}
|
|
100
102
|
if (renders.length === 0) {
|
|
@@ -117,11 +119,11 @@ var Publications = class {
|
|
|
117
119
|
continue;
|
|
118
120
|
}
|
|
119
121
|
if (driftSeverity === "off") continue;
|
|
120
|
-
if (entry.
|
|
122
|
+
if (entry.body === void 0) {
|
|
121
123
|
stale.push(`${sourceId} (never stamped)`);
|
|
122
124
|
continue;
|
|
123
125
|
}
|
|
124
|
-
if ((yield* Derivation.bodyDigest(source.document.source)) !== entry.
|
|
126
|
+
if ((yield* Derivation.bodyDigest(source.document.source)) !== entry.body) stale.push(`${sourceId} (changed)`);
|
|
125
127
|
}
|
|
126
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"]);
|
|
127
129
|
}
|
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]
|
|
@@ -290,8 +290,8 @@ Each directory carries its own `index.md`. What `okfit init` writes into these f
|
|
|
290
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.
|
|
291
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.
|
|
292
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.
|
|
293
|
-
- **The body digest answers "has the body changed", where dates cannot (issue #19).** `Derivation.bodyDigest(text)` is a
|
|
294
|
-
- **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 `-`.
|
|
295
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.
|
|
296
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.
|
|
297
297
|
|
|
@@ -313,7 +313,7 @@ Frontmatter serialisation, the uncommitted policy's enforcement, `now`, layer co
|
|
|
313
313
|
lint (`lint.generated_at_drift`, config default `"warn"`), and it runs in
|
|
314
314
|
two tiers (issue #19).
|
|
315
315
|
|
|
316
|
-
Tier 1 applies to a concept that records `generated.
|
|
316
|
+
Tier 1 applies to a concept that records `integrity.generated.body`: the
|
|
317
317
|
recorded digest is compared against
|
|
318
318
|
`Derivation.bodyDigest(concept.document.source)`, a pure comparison over
|
|
319
319
|
text already in memory with no git call and no filesystem read. Drift here
|
package/SoftwareProject.js
CHANGED
|
@@ -58,7 +58,7 @@ const config = {
|
|
|
58
58
|
},
|
|
59
59
|
Decision: {
|
|
60
60
|
description: "A choice made, the alternatives rejected, and why.",
|
|
61
|
-
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.",
|
|
62
62
|
require_verified: true,
|
|
63
63
|
fields: { supersedes: {
|
|
64
64
|
description: "The Decision this one replaces.",
|
package/Trust.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Derivation } from "./Derivation.js";
|
|
2
|
+
import { Actor, Derive, Diagnostic, DiagnosticRange, OkfitConfig, Timestamp } from "@okfit/core";
|
|
3
|
+
import { DateTime, Effect, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/Trust.ts
|
|
6
|
+
const encodeAt = Schema.encodeSync(Timestamp);
|
|
7
|
+
/**
|
|
8
|
+
* Why a human verification no longer binds: the content changed, the entry has
|
|
9
|
+
* no `integrity.verified` binding, a later human entry is not the bound one, or
|
|
10
|
+
* the bound actor fails a non-empty `[actors].humans` allow-list.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
const DriftReason = Schema.Literals([
|
|
15
|
+
"content-changed",
|
|
16
|
+
"unbound",
|
|
17
|
+
"entry-superseded",
|
|
18
|
+
"actor-not-allowed"
|
|
19
|
+
]);
|
|
20
|
+
/** Only `human:` entries bind; machine entries neither bind nor break a binding. */
|
|
21
|
+
const latestHumanOf = (entries) => entries.filter((entry) => Actor.isHuman(entry.by)).reduce((latest, entry) => latest === void 0 || DateTime.isGreaterThanOrEqualTo(entry.at, latest.at) ? entry : latest, void 0);
|
|
22
|
+
const latestHuman = (concept) => latestHumanOf(concept.frontmatter.verified ?? []);
|
|
23
|
+
const inputOf = (concept, config) => ({
|
|
24
|
+
verified: concept.frontmatter.verified ?? [],
|
|
25
|
+
binding: concept.frontmatter.integrity?.verified,
|
|
26
|
+
generated: concept.frontmatter.generated,
|
|
27
|
+
humans: config.actors?.humans ?? []
|
|
28
|
+
});
|
|
29
|
+
/** The binding when it names the latest human entry; `undefined` when none, unbound or superseded. */
|
|
30
|
+
const boundEntry = (input) => {
|
|
31
|
+
const latest = latestHumanOf(input.verified);
|
|
32
|
+
const binding = input.binding;
|
|
33
|
+
if (latest === void 0 || binding === void 0) return void 0;
|
|
34
|
+
return binding.by === latest.by && DateTime.Equivalence(binding.at, latest.at) ? binding : void 0;
|
|
35
|
+
};
|
|
36
|
+
const needsDigest = (input) => {
|
|
37
|
+
const bound = boundEntry(input);
|
|
38
|
+
return bound !== void 0 && Actor.allowed(bound.by, input.humans);
|
|
39
|
+
};
|
|
40
|
+
const evaluate = (input, content) => {
|
|
41
|
+
if (latestHumanOf(input.verified) === void 0) return { integrity: "none" };
|
|
42
|
+
const binding = input.binding;
|
|
43
|
+
if (binding === void 0) return {
|
|
44
|
+
integrity: "drifted",
|
|
45
|
+
drift: { reason: "unbound" }
|
|
46
|
+
};
|
|
47
|
+
if (boundEntry(input) === void 0) return {
|
|
48
|
+
integrity: "drifted",
|
|
49
|
+
drift: { reason: "entry-superseded" }
|
|
50
|
+
};
|
|
51
|
+
if (!Actor.allowed(binding.by, input.humans)) return {
|
|
52
|
+
integrity: "drifted",
|
|
53
|
+
drift: { reason: "actor-not-allowed" }
|
|
54
|
+
};
|
|
55
|
+
if (content === void 0) throw new Error("Trust.evaluate: a bound verification needs a content digest");
|
|
56
|
+
if (content === binding.content) return { integrity: "current" };
|
|
57
|
+
const generated = input.generated;
|
|
58
|
+
return {
|
|
59
|
+
integrity: "drifted",
|
|
60
|
+
drift: {
|
|
61
|
+
reason: "content-changed",
|
|
62
|
+
...generated?.at !== void 0 && DateTime.isGreaterThan(generated.at, binding.at) ? {
|
|
63
|
+
editedBy: generated.by,
|
|
64
|
+
editedAt: encodeAt(generated.at)
|
|
65
|
+
} : {}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const signal = Effect.fn("Trust.signal")(function* (concept, config, now) {
|
|
70
|
+
const tier = Derive.trustTier(concept.frontmatter);
|
|
71
|
+
const stale = Derive.isStale(concept.frontmatter, now);
|
|
72
|
+
const input = inputOf(concept, config);
|
|
73
|
+
const content = needsDigest(input) ? yield* Derivation.contentDigest(concept.frontmatter.raw, concept.document.source) : void 0;
|
|
74
|
+
return {
|
|
75
|
+
tier,
|
|
76
|
+
stale,
|
|
77
|
+
...evaluate(input, content)
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const signals = Effect.fn("Trust.signals")(function* (bundle, config, now) {
|
|
81
|
+
const out = /* @__PURE__ */ new Map();
|
|
82
|
+
for (const [id, concept] of bundle.concepts) out.set(id, yield* signal(concept, config, now));
|
|
83
|
+
return out;
|
|
84
|
+
});
|
|
85
|
+
const messageFor = (concept, drift) => {
|
|
86
|
+
const latest = latestHuman(concept);
|
|
87
|
+
const by = latest?.by ?? "a human";
|
|
88
|
+
const at = latest === void 0 ? "an unknown time" : encodeAt(latest.at);
|
|
89
|
+
if (drift.reason === "unbound") return `verified by ${by} at ${at}, but no integrity.verified binding records what was verified; re-run okfit verify`;
|
|
90
|
+
if (drift.reason === "entry-superseded") return `the latest human verification (${by} at ${at}) is not the one integrity.verified binds; re-run okfit verify`;
|
|
91
|
+
const binding = concept.frontmatter.integrity?.verified;
|
|
92
|
+
if (drift.reason === "actor-not-allowed") return `verified by ${binding?.by ?? by}, who is not in [actors].humans; a listed human re-runs okfit verify`;
|
|
93
|
+
const edited = drift.editedBy === void 0 ? "" : ` (edited by ${drift.editedBy} at ${drift.editedAt})`;
|
|
94
|
+
return `content changed since ${binding?.by ?? by} verified it at ${binding === void 0 ? at : encodeAt(binding.at)}${edited}; re-run okfit verify`;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Where a `verified-drift` finding points (spec section 6): the binding when one exists,
|
|
98
|
+
* else the latest human `verified` entry -- by its list index when `verified` is a
|
|
99
|
+
* sequence, the key itself when it is a single bare mapping.
|
|
100
|
+
*/
|
|
101
|
+
const rangePath = (concept) => {
|
|
102
|
+
if (concept.frontmatter.integrity?.verified !== void 0) return ["integrity", "verified"];
|
|
103
|
+
const latest = latestHuman(concept);
|
|
104
|
+
const index = latest === void 0 ? -1 : (concept.frontmatter.verified ?? []).indexOf(latest);
|
|
105
|
+
return Array.isArray(concept.frontmatter.raw.verified) && index >= 0 ? ["verified", index] : ["verified"];
|
|
106
|
+
};
|
|
107
|
+
const lint = Effect.fn("Trust.lint")(function* (bundle, config, now, given) {
|
|
108
|
+
const severity = OkfitConfig.severityFor(config, "verified-drift");
|
|
109
|
+
if (severity === "off") return [];
|
|
110
|
+
const diagnostics = [];
|
|
111
|
+
for (const [id, concept] of bundle.concepts) {
|
|
112
|
+
const status = Derive.status(concept.frontmatter);
|
|
113
|
+
if (status === "draft" || status === "deprecated") continue;
|
|
114
|
+
const result = given?.get(id) ?? (yield* signal(concept, config, now));
|
|
115
|
+
if (result.drift === void 0) continue;
|
|
116
|
+
const range = DiagnosticRange.forFrontmatterPath(concept.document, rangePath(concept));
|
|
117
|
+
diagnostics.push(Diagnostic.make({
|
|
118
|
+
file: concept.path,
|
|
119
|
+
code: "verified-drift",
|
|
120
|
+
severity,
|
|
121
|
+
message: messageFor(concept, result.drift),
|
|
122
|
+
...range === void 0 ? {} : { range }
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
return diagnostics;
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* The integrity trust signal: whether a human verification still binds the
|
|
129
|
+
* content it covered, and the `verified-drift` lint built on it. A signal, not
|
|
130
|
+
* a tamper seal. Named `Trust` so it does not collide with core's `Integrity`
|
|
131
|
+
* schema.
|
|
132
|
+
*
|
|
133
|
+
* @public
|
|
134
|
+
*/
|
|
135
|
+
var Trust = class {
|
|
136
|
+
constructor() {}
|
|
137
|
+
/** The signal for one concept under `config`; hashes only when a human entry is bound by an allowed actor. */
|
|
138
|
+
static signal = signal;
|
|
139
|
+
/** The signal for every concept in a bundle, keyed by concept id. */
|
|
140
|
+
static signals = signals;
|
|
141
|
+
/** Pure integrity decision; no hashing. `content` may be `undefined` only when {@link Trust.needsDigest} is false. */
|
|
142
|
+
static evaluate = evaluate;
|
|
143
|
+
/** True iff the latest human entry is bound by `integrity.verified` and its actor passes the allow-list, i.e. a digest is needed. */
|
|
144
|
+
static needsDigest = needsDigest;
|
|
145
|
+
/** The {@link EvaluateInput} of a loaded concept. */
|
|
146
|
+
static inputOf = inputOf;
|
|
147
|
+
/**
|
|
148
|
+
* `verified-drift` diagnostics for drifted concepts (drafts and deprecated concepts are exempt: `okfit verify` refuses them); `[]` (no hashing) when the severity is `off`.
|
|
149
|
+
* Given `signals` (from {@link Trust.signals}) it reuses each concept's entry and hashes nothing for it;
|
|
150
|
+
* a concept absent from `signals` falls back to computing its own signal (hashing if it needs a digest).
|
|
151
|
+
*/
|
|
152
|
+
static lint = lint;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
export { DriftReason, Trust };
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context, Crypto, DateTime, Effect, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
|
|
2
2
|
import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
|
|
3
|
-
import { Actor, Diagnostic, DiagnosticRange, LoadedBundle, LoadedConcept, OkfitConfig } from "@okfit/core";
|
|
3
|
+
import { Actor, Diagnostic, DiagnosticRange, Digest, LoadedBundle, LoadedConcept, OkfitConfig, TrustTier } from "@okfit/core";
|
|
4
4
|
import { ChildProcessSpawner } from "effect/process";
|
|
5
5
|
//#region src/BodyProvenance.d.ts
|
|
6
6
|
/**
|
|
@@ -60,6 +60,22 @@ export type BodyProvenance = typeof BodyProvenance.Type;
|
|
|
60
60
|
*/
|
|
61
61
|
type UncommittedReason = typeof BodyUncommitted.fields.reason.Type;
|
|
62
62
|
//#endregion
|
|
63
|
+
//#region src/Canonical.d.ts
|
|
64
|
+
/**
|
|
65
|
+
* Canonical encoding for okfit's content digest.
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
export declare class Canonical {
|
|
70
|
+
private constructor();
|
|
71
|
+
/** Top-level frontmatter keys excluded from the content digest. */
|
|
72
|
+
static readonly EXCLUDED_KEYS: ReadonlySet<string>;
|
|
73
|
+
/** Canonical JSON: sorted keys at every depth, array order kept; a `bigint` is its bare decimal literal. */
|
|
74
|
+
static readonly json: (value: unknown) => string;
|
|
75
|
+
/** The exact string a content digest hashes. */
|
|
76
|
+
static readonly contentInput: (raw: Readonly<Record<string, unknown>>, source: string) => string;
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
63
79
|
//#region src/GitHistory.d.ts
|
|
64
80
|
declare const PathHistoryEntry_base: Schema.Class<PathHistoryEntry, Schema.Struct<{
|
|
65
81
|
readonly sha: Schema.String;
|
|
@@ -178,6 +194,31 @@ declare const AgentActorUnconfiguredError_base: Schema.Class<AgentActorUnconfigu
|
|
|
178
194
|
export declare class AgentActorUnconfiguredError extends AgentActorUnconfiguredError_base {
|
|
179
195
|
get message(): string;
|
|
180
196
|
}
|
|
197
|
+
declare const ActorConfigInvalidError_base: Schema.Class<ActorConfigInvalidError, Schema.TaggedStruct<"ActorConfigInvalidError", {
|
|
198
|
+
readonly value: Schema.String;
|
|
199
|
+
}>, import("effect/Cause").YieldableError>;
|
|
200
|
+
/**
|
|
201
|
+
* `git config okfit.actor` is set but is not a `human:<id>` actor without `@`
|
|
202
|
+
* or glob characters. The value stays on the person's machine; okfit never
|
|
203
|
+
* writes it anywhere.
|
|
204
|
+
*
|
|
205
|
+
* @public
|
|
206
|
+
*/
|
|
207
|
+
export declare class ActorConfigInvalidError extends ActorConfigInvalidError_base {
|
|
208
|
+
get message(): string;
|
|
209
|
+
}
|
|
210
|
+
declare const HumanActorNotAllowedError_base: Schema.Class<HumanActorNotAllowedError, Schema.TaggedStruct<"HumanActorNotAllowedError", {
|
|
211
|
+
readonly actor: Schema.String;
|
|
212
|
+
}>, import("effect/Cause").YieldableError>;
|
|
213
|
+
/**
|
|
214
|
+
* The resolved human is not on a non-empty `[actors].humans` allow-list, so it
|
|
215
|
+
* may not verify or revoke (spec section 8).
|
|
216
|
+
*
|
|
217
|
+
* @public
|
|
218
|
+
*/
|
|
219
|
+
export declare class HumanActorNotAllowedError extends HumanActorNotAllowedError_base {
|
|
220
|
+
get message(): string;
|
|
221
|
+
}
|
|
181
222
|
/**
|
|
182
223
|
* Options for {@link Derivation.generatedAt} (P-39).
|
|
183
224
|
*
|
|
@@ -214,7 +255,13 @@ interface GeneratedByOptions {
|
|
|
214
255
|
*
|
|
215
256
|
* @public
|
|
216
257
|
*/
|
|
217
|
-
type GeneratedByError = AgentActorUnconfiguredError | HumanActorUnresolvedError | GitCommandError | NotARepositoryError | UnknownRefError;
|
|
258
|
+
type GeneratedByError = ActorConfigInvalidError | AgentActorUnconfiguredError | HumanActorUnresolvedError | GitCommandError | NotARepositoryError | UnknownRefError;
|
|
259
|
+
/**
|
|
260
|
+
* Everything {@link Derivation.verifier} can fail with.
|
|
261
|
+
*
|
|
262
|
+
* @public
|
|
263
|
+
*/
|
|
264
|
+
type VerifierError = GeneratedByError | HumanActorNotAllowedError;
|
|
218
265
|
/**
|
|
219
266
|
* Trimmed `user.name` / `user.email` as `Git.configGet` answers them; a key is absent when git has no value.
|
|
220
267
|
*
|
|
@@ -240,11 +287,32 @@ export declare class Derivation {
|
|
|
240
287
|
*/
|
|
241
288
|
static readonly body: (text: string) => string;
|
|
242
289
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
290
|
+
* Full lowercase 64-char hex sha256 of a string. Tests only.
|
|
291
|
+
*
|
|
292
|
+
* @internal
|
|
293
|
+
*/
|
|
294
|
+
static readonly digestFull: (text: string) => Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>;
|
|
295
|
+
/**
|
|
296
|
+
* The okfit digest of a string: the first 16 lowercase hex characters of its SHA-256, through
|
|
297
|
+
* `effect`'s platform-agnostic `Crypto` service.
|
|
298
|
+
*/
|
|
299
|
+
static readonly digest: (text: string) => Effect.Effect<Digest, PlatformError.PlatformError, Crypto.Crypto>;
|
|
300
|
+
/**
|
|
301
|
+
* 16-hex digest of `Derivation.body(source)` (issue #19). Same normalization as `body`, so a
|
|
302
|
+
* frontmatter-only edit or a `core.autocrlf` worktree never changes the digest.
|
|
303
|
+
*/
|
|
304
|
+
static readonly bodyDigest: (source: string) => Effect.Effect<Digest, PlatformError.PlatformError, Crypto.Crypto>;
|
|
305
|
+
/**
|
|
306
|
+
* The body digest recorded for a generated concept: `integrity.generated.body`, else the legacy
|
|
307
|
+
* `generated.body_sha256` cut to its first 16 hex characters. `undefined` when neither is present.
|
|
308
|
+
* Pure; every "is the stamped body still current" comparison reads through this.
|
|
309
|
+
*/
|
|
310
|
+
static readonly recordedBody: (concept: LoadedConcept) => string | undefined;
|
|
311
|
+
/**
|
|
312
|
+
* 16-hex digest of the canonical content input (`Canonical.contentInput`): authored frontmatter
|
|
313
|
+
* plus the normalised body, insensitive to style, key order and CRLF.
|
|
246
314
|
*/
|
|
247
|
-
static readonly
|
|
315
|
+
static readonly contentDigest: (raw: Readonly<Record<string, unknown>>, source: string) => Effect.Effect<Digest, PlatformError.PlatformError, Crypto.Crypto>;
|
|
248
316
|
/**
|
|
249
317
|
* Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
|
|
250
318
|
* 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
|
|
@@ -265,18 +333,30 @@ export declare class Derivation {
|
|
|
265
333
|
static readonly generatedAt: (options: GeneratedAtOptions) => Effect.Effect<BodyProvenance, GeneratedAtError, Git | GitHistory | FileSystem.FileSystem | Path.Path>;
|
|
266
334
|
/**
|
|
267
335
|
* P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
|
|
268
|
-
* and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans`
|
|
269
|
-
* candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
|
|
336
|
+
* and the `user.name` slug. Order, first hit wins: (1) a literal `human:` entry of `humans` (glob entries,
|
|
337
|
+
* those with `*` or `?`, are skipped) whose id equals a candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
|
|
270
338
|
* The result is branded through core's `Actor` codec and always passes it.
|
|
271
339
|
*/
|
|
272
|
-
static readonly humanActorId: (identity: GitIdentity, humans: ReadonlyArray<
|
|
340
|
+
static readonly humanActorId: (identity: GitIdentity, humans: ReadonlyArray<string>) => Option.Option<Actor>;
|
|
273
341
|
/**
|
|
274
342
|
* Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
|
|
275
|
-
* (P-17). `"human"`: `Git.configGet(cwd, "
|
|
343
|
+
* (P-17). `"human"`: first `Git.configGet(cwd, "okfit.actor")`, which wins when set and must be a
|
|
344
|
+
* `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
|
|
276
345
|
* scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
|
|
277
346
|
* Answers only "who is writing now" (P-40).
|
|
278
347
|
*/
|
|
279
348
|
static readonly generatedBy: (options: GeneratedByOptions) => Effect.Effect<Actor, GeneratedByError, Git>;
|
|
349
|
+
/**
|
|
350
|
+
* The human allowed to verify now: {@link Derivation.generatedBy} for a human
|
|
351
|
+
* writer, then the `[actors].humans` allow-list (`Actor.allowed`): empty or
|
|
352
|
+
* absent allows anyone, otherwise the actor must match an entry or this fails
|
|
353
|
+
* {@link HumanActorNotAllowedError}. `okfit verify`, `--revoke` and the LSP
|
|
354
|
+
* verify actions all resolve through this.
|
|
355
|
+
*/
|
|
356
|
+
static readonly verifier: (options: {
|
|
357
|
+
readonly cwd: string;
|
|
358
|
+
readonly config: OkfitConfig;
|
|
359
|
+
}) => Effect.Effect<Actor, VerifierError, Git>;
|
|
280
360
|
/**
|
|
281
361
|
* P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
|
|
282
362
|
* 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
|
|
@@ -431,17 +511,20 @@ export declare class Profiles {
|
|
|
431
511
|
* like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
|
|
432
512
|
* are unchanged.
|
|
433
513
|
*
|
|
434
|
-
* Two tiers per concept (issue #19, body-digest design): when
|
|
435
|
-
* `generated.
|
|
514
|
+
* Two tiers per concept (issue #19, body-digest design): when a body digest
|
|
515
|
+
* is recorded (`integrity.generated.body`, else the 16-hex prefix of a legacy
|
|
516
|
+
* `generated.body_sha256`, via `Derivation.recordedBody`), drift is decided
|
|
517
|
+
* ENTIRELY by comparing it to
|
|
436
518
|
* `Derivation.bodyDigest` of the concept's current source -- no git call at
|
|
437
519
|
* all, so this works for a dirty worktree too (that is the point: an edited
|
|
438
520
|
* body is caught immediately, before it is even committed) and survives a
|
|
439
521
|
* squash/rebase merge that only rewrites commit dates, since the body
|
|
440
|
-
* itself, and therefore its digest, never changed. When
|
|
441
|
-
*
|
|
522
|
+
* itself, and therefore its digest, never changed. When no digest is recorded
|
|
523
|
+
* at all (a concept `okfit sync` has never stamped), this falls back to the original
|
|
442
524
|
* git-derived date comparison against `Derivation.generatedAt`, preserving
|
|
443
525
|
* `--skip-provenance`'s meaning for bundles that have not run `okfit sync`
|
|
444
|
-
* since
|
|
526
|
+
* since the digest was introduced. The drift message names the field the
|
|
527
|
+
* recorded value came from, so a legacy concept reads `generated.body_sha256`.
|
|
445
528
|
*
|
|
446
529
|
* The tier-2 fallback returns `[]` the moment a `NotARepositoryError`
|
|
447
530
|
* surfaces: a bundle outside git has nothing to derive for any concept still
|
|
@@ -459,8 +542,8 @@ export declare class Profiles {
|
|
|
459
542
|
* calling at all; the two agree by construction (both skip the walk).
|
|
460
543
|
*
|
|
461
544
|
* A third, optional options argument carries `skipGitTier`: when `true`, a
|
|
462
|
-
* concept that falls through to tier 2 (no
|
|
463
|
-
*
|
|
545
|
+
* concept that falls through to tier 2 (no body digest recorded) is
|
|
546
|
+
* skipped entirely -- no `Derivation.generatedAt` call, no
|
|
464
547
|
* git spawn at all -- while tier 1 still runs, unaffected, for every
|
|
465
548
|
* concept that DOES carry a digest. This is a runtime skip, not a
|
|
466
549
|
* type-level narrowing: the R channel still lists `Git | GitHistory` even
|
|
@@ -499,13 +582,13 @@ export declare const PUBLICATION_TYPE: "Publication";
|
|
|
499
582
|
interface RendersEntry {
|
|
500
583
|
readonly index: number;
|
|
501
584
|
readonly path: string;
|
|
502
|
-
readonly
|
|
585
|
+
readonly body?: string;
|
|
503
586
|
}
|
|
504
587
|
/**
|
|
505
588
|
* A profiles-owned lint over Publication concepts: `publication-orphan` when a
|
|
506
589
|
* `surface` or `renders[].path` does not resolve to a bundle concept (or
|
|
507
590
|
* `renders` is malformed), and `publication-drift` when a rendered source's
|
|
508
|
-
* body digest no longer matches the stamped `
|
|
591
|
+
* body digest no longer matches the stamped `renders[].body`. Total over
|
|
509
592
|
* severity: returns `[]` before any digest work when both codes are `"off"`.
|
|
510
593
|
*
|
|
511
594
|
* @public
|
|
@@ -520,5 +603,98 @@ export declare class Publications {
|
|
|
520
603
|
static readonly lint: (bundle: LoadedBundle, config: OkfitConfig) => Effect.Effect<ReadonlyArray<Diagnostic>, PlatformError.PlatformError, Crypto.Crypto>;
|
|
521
604
|
}
|
|
522
605
|
//#endregion
|
|
523
|
-
|
|
606
|
+
//#region src/Trust.d.ts
|
|
607
|
+
/**
|
|
608
|
+
* Whether a concept's human verification still covers its content: `none` (no
|
|
609
|
+
* `human:` entry in `verified`), `current` (the latest human entry is bound by
|
|
610
|
+
* `integrity.verified` and the content digest still matches), or `drifted`.
|
|
611
|
+
*
|
|
612
|
+
* @public
|
|
613
|
+
*/
|
|
614
|
+
type IntegrityState = "current" | "drifted" | "none";
|
|
615
|
+
/**
|
|
616
|
+
* Why a human verification no longer binds: the content changed, the entry has
|
|
617
|
+
* no `integrity.verified` binding, a later human entry is not the bound one, or
|
|
618
|
+
* the bound actor fails a non-empty `[actors].humans` allow-list.
|
|
619
|
+
*
|
|
620
|
+
* @public
|
|
621
|
+
*/
|
|
622
|
+
export declare const DriftReason: Schema.Literals<readonly ["content-changed", "unbound", "entry-superseded", "actor-not-allowed"]>;
|
|
623
|
+
/**
|
|
624
|
+
* The type of {@link (DriftReason:variable)}.
|
|
625
|
+
* @public
|
|
626
|
+
*/
|
|
627
|
+
export type DriftReason = typeof DriftReason.Type;
|
|
628
|
+
/**
|
|
629
|
+
* The trust signal for one concept: its tier, whether its human verification
|
|
630
|
+
* still binds, and staleness. `drift` is present only when `integrity` is
|
|
631
|
+
* `drifted`.
|
|
632
|
+
*
|
|
633
|
+
* @public
|
|
634
|
+
*/
|
|
635
|
+
interface ConceptSignal {
|
|
636
|
+
readonly tier: TrustTier;
|
|
637
|
+
readonly integrity: IntegrityState;
|
|
638
|
+
readonly drift?: {
|
|
639
|
+
readonly reason: DriftReason;
|
|
640
|
+
readonly editedBy?: Actor;
|
|
641
|
+
readonly editedAt?: string;
|
|
642
|
+
};
|
|
643
|
+
readonly stale: boolean;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* What the integrity decision reads from a concept: its `verified` entries, the
|
|
647
|
+
* `integrity.verified` binding, and `generated`. Built by {@link Trust.inputOf}.
|
|
648
|
+
*
|
|
649
|
+
* @public
|
|
650
|
+
*/
|
|
651
|
+
interface EvaluateInput {
|
|
652
|
+
readonly verified: ReadonlyArray<{
|
|
653
|
+
readonly by: Actor;
|
|
654
|
+
readonly at: DateTime.Utc;
|
|
655
|
+
}>;
|
|
656
|
+
readonly binding: {
|
|
657
|
+
readonly by: Actor;
|
|
658
|
+
readonly at: DateTime.Utc;
|
|
659
|
+
readonly content: string;
|
|
660
|
+
} | undefined;
|
|
661
|
+
readonly generated: {
|
|
662
|
+
readonly by: Actor;
|
|
663
|
+
readonly at?: DateTime.Utc;
|
|
664
|
+
} | undefined;
|
|
665
|
+
/** The `[actors].humans` allow-list; empty allows anyone. */
|
|
666
|
+
readonly humans: ReadonlyArray<string>;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The integrity trust signal: whether a human verification still binds the
|
|
670
|
+
* content it covered, and the `verified-drift` lint built on it. A signal, not
|
|
671
|
+
* a tamper seal. Named `Trust` so it does not collide with core's `Integrity`
|
|
672
|
+
* schema.
|
|
673
|
+
*
|
|
674
|
+
* @public
|
|
675
|
+
*/
|
|
676
|
+
export declare class Trust {
|
|
677
|
+
private constructor();
|
|
678
|
+
/** The signal for one concept under `config`; hashes only when a human entry is bound by an allowed actor. */
|
|
679
|
+
static readonly signal: (concept: LoadedConcept, config: OkfitConfig, now: DateTime.Utc) => Effect.Effect<ConceptSignal, PlatformError.PlatformError, Crypto.Crypto>;
|
|
680
|
+
/** The signal for every concept in a bundle, keyed by concept id. */
|
|
681
|
+
static readonly signals: (bundle: LoadedBundle, config: OkfitConfig, now: DateTime.Utc) => Effect.Effect<Map<string, ConceptSignal>, PlatformError.PlatformError, Crypto.Crypto>;
|
|
682
|
+
/** Pure integrity decision; no hashing. `content` may be `undefined` only when {@link Trust.needsDigest} is false. */
|
|
683
|
+
static readonly evaluate: (input: EvaluateInput, content: string | undefined) => {
|
|
684
|
+
readonly integrity: IntegrityState;
|
|
685
|
+
readonly drift?: NonNullable<ConceptSignal["drift"]>;
|
|
686
|
+
};
|
|
687
|
+
/** True iff the latest human entry is bound by `integrity.verified` and its actor passes the allow-list, i.e. a digest is needed. */
|
|
688
|
+
static readonly needsDigest: (input: EvaluateInput) => boolean;
|
|
689
|
+
/** The {@link EvaluateInput} of a loaded concept. */
|
|
690
|
+
static readonly inputOf: (concept: LoadedConcept, config: OkfitConfig) => EvaluateInput;
|
|
691
|
+
/**
|
|
692
|
+
* `verified-drift` diagnostics for drifted concepts (drafts and deprecated concepts are exempt: `okfit verify` refuses them); `[]` (no hashing) when the severity is `off`.
|
|
693
|
+
* Given `signals` (from {@link Trust.signals}) it reuses each concept's entry and hashes nothing for it;
|
|
694
|
+
* a concept absent from `signals` falls back to computing its own signal (hashing if it needs a digest).
|
|
695
|
+
*/
|
|
696
|
+
static readonly lint: (bundle: LoadedBundle, config: OkfitConfig, now: DateTime.Utc, signals?: ReadonlyMap<string, ConceptSignal>) => Effect.Effect<Array<Diagnostic>, PlatformError.PlatformError, Crypto.Crypto>;
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
export type { ConceptSignal, DocsPreset, EvaluateInput, GeneratedAtError, GeneratedAtOptions, GeneratedByError, GeneratedByOptions, GitHistoryShape, GitIdentity, IntegrityState, Layout, LayoutDirectory, PathLogOptions, Profile, ProfileName, RendersEntry, SurfaceTemplate, UncommittedReason, VerifierError, Writer };
|
|
524
700
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { BodyCommitted, BodyProvenance, BodyUncommitted } from "./BodyProvenance.js";
|
|
2
|
+
import { Canonical } from "./Canonical.js";
|
|
2
3
|
import { GitHistory, GitHistoryError, PathHistoryEntry } from "./GitHistory.js";
|
|
3
|
-
import { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError } from "./Derivation.js";
|
|
4
|
+
import { ActorConfigInvalidError, AgentActorUnconfiguredError, Derivation, HumanActorNotAllowedError, HumanActorUnresolvedError } from "./Derivation.js";
|
|
4
5
|
import { PROFILE_NAMES, ProfileDiagnostic, ProfileDiagnosticCode } from "./Profile.js";
|
|
5
6
|
import { Profiles } from "./Profiles.js";
|
|
6
7
|
import { Provenance } from "./Provenance.js";
|
|
7
8
|
import { PUBLICATION_TYPE, Publications, SURFACE_TYPE } from "./Publications.js";
|
|
9
|
+
import { DriftReason, Trust } from "./Trust.js";
|
|
8
10
|
|
|
9
|
-
export { AgentActorUnconfiguredError, BodyCommitted, BodyProvenance, BodyUncommitted, Derivation, GitHistory, GitHistoryError, HumanActorUnresolvedError, PROFILE_NAMES, PUBLICATION_TYPE, PathHistoryEntry, ProfileDiagnostic, ProfileDiagnosticCode, Profiles, Provenance, Publications, SURFACE_TYPE };
|
|
11
|
+
export { ActorConfigInvalidError, AgentActorUnconfiguredError, BodyCommitted, BodyProvenance, BodyUncommitted, Canonical, Derivation, DriftReason, GitHistory, GitHistoryError, HumanActorNotAllowedError, HumanActorUnresolvedError, PROFILE_NAMES, PUBLICATION_TYPE, PathHistoryEntry, ProfileDiagnostic, ProfileDiagnosticCode, Profiles, Provenance, Publications, SURFACE_TYPE, Trust };
|
package/internal/body.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FrontmatterSource } from "@effected/markdown";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/body.ts
|
|
4
|
+
const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
|
|
5
|
+
/**
|
|
6
|
+
* The normalised body of a concept source (`FrontmatterSource.split(text).body`, then P-6 normalisation).
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const body = (text) => normalise(FrontmatterSource.split(text).body);
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
export { body };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okfit/profiles",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Named OKF configuration profiles for okfit, starting with the software-project profile.",
|
|
6
6
|
"keywords": [
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"./package.json": "./package.json"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@effected/git": "^0.
|
|
39
|
-
"@effected/markdown": "^0.
|
|
40
|
-
"@okfit/core": "^0.
|
|
38
|
+
"@effected/git": "^0.21.0",
|
|
39
|
+
"@effected/markdown": "^0.16.0",
|
|
40
|
+
"@okfit/core": "^0.10.0",
|
|
41
41
|
"effect": "^4.0.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|