@okfit/profiles 0.1.0 → 0.3.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.
@@ -0,0 +1,44 @@
1
+ import { Timestamp } from "@okfit/core";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/BodyProvenance.ts
5
+ /**
6
+ * The body is at HEAD and last changed in the commit described here (P-9).
7
+ * `at` is that commit's author date (P-4); `committedAt` and the author
8
+ * identity are carried for callers that want a history-based policy later (P-40).
9
+ * `creating` is true exactly when the winning entry is also the oldest entry
10
+ * of the path's history — the commit that created the path (S-9).
11
+ *
12
+ * @public
13
+ */
14
+ const BodyCommitted = Schema.TaggedStruct("committed", {
15
+ at: Timestamp,
16
+ sha: Schema.String,
17
+ committedAt: Timestamp,
18
+ authorName: Schema.String,
19
+ authorEmail: Schema.String,
20
+ creating: Schema.Boolean
21
+ });
22
+ /**
23
+ * The body has no usable commit (P-9): `untracked` means HEAD does not contain
24
+ * the path, `dirty` means the on-disk body differs from HEAD's blob, `unborn`
25
+ * means HEAD has no commits (P-11).
26
+ *
27
+ * @public
28
+ */
29
+ const BodyUncommitted = Schema.TaggedStruct("uncommitted", { reason: Schema.Literals([
30
+ "untracked",
31
+ "dirty",
32
+ "unborn"
33
+ ]) });
34
+ /**
35
+ * What {@link Derivation.generatedAt} reports. A schema so the value also
36
+ * serialises for MCP later without adaptation. Derivation never substitutes
37
+ * `now`; the uncommitted policy is the caller's (P-10).
38
+ *
39
+ * @public
40
+ */
41
+ const BodyProvenance = Schema.Union([BodyCommitted, BodyUncommitted]);
42
+
43
+ //#endregion
44
+ export { BodyCommitted, BodyProvenance, BodyUncommitted };
package/Derivation.js ADDED
@@ -0,0 +1,200 @@
1
+ import { GitHistory } from "./GitHistory.js";
2
+ import { Actor, OkfitConfig } from "@okfit/core";
3
+ import { Crypto, DateTime, Duration, Effect, Encoding, FileSystem, Option, Path, Schema } from "effect";
4
+ import { Git } from "@effected/git";
5
+ import { FrontmatterSource } from "@effected/markdown";
6
+
7
+ //#region src/Derivation.ts
8
+ /**
9
+ * git has no identity to derive a `human:` actor from (P-13 step 4).
10
+ * `userName` and `userEmail` are the trimmed `configGet` answers when git had
11
+ * them and absent keys otherwise (decision 53: not `name`/`email`, since every
12
+ * Effect error class carries the class name on its own prototype and a field
13
+ * literally named `name` would shadow `error.name` when present); test for
14
+ * git's answer with `Object.hasOwn(error, "userName")`.
15
+ *
16
+ * @public
17
+ */
18
+ var HumanActorUnresolvedError = class extends Schema.TaggedError()("HumanActorUnresolvedError", {
19
+ userName: Schema.optionalKey(Schema.String),
20
+ userEmail: Schema.optionalKey(Schema.String)
21
+ }) {
22
+ get message() {
23
+ return "git has no user.name or user.email to derive a human actor from; set one or add actors.humans";
24
+ }
25
+ };
26
+ /**
27
+ * The writer is an agent but `actors.agent` is not configured (P-17); the
28
+ * profile never sets it.
29
+ *
30
+ * @public
31
+ */
32
+ var AgentActorUnconfiguredError = class extends Schema.TaggedError()("AgentActorUnconfiguredError", {}) {
33
+ get message() {
34
+ return "writer is agent but actors.agent is not configured";
35
+ }
36
+ };
37
+ const HUMAN_PREFIX = "human:";
38
+ const FALLBACK_STALE_AFTER = Duration.days(90);
39
+ const decodeActor = Schema.decodeSync(Actor);
40
+ const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
41
+ const body = (text) => normalise(FrontmatterSource.split(text).body);
42
+ const bodyDigest = (text) => Effect.gen(function* () {
43
+ const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(body(text)));
44
+ return Encoding.encodeHex(bytes);
45
+ });
46
+ /** The text before the first `@` (the whole value when there is none), or undefined when empty or containing whitespace. */
47
+ const localPartOf = (email) => {
48
+ if (email === void 0) return void 0;
49
+ const at = email.indexOf("@");
50
+ const local = at === -1 ? email : email.slice(0, at);
51
+ return local.length > 0 && !/\s/.test(local) ? local : void 0;
52
+ };
53
+ /** P-13 step 3: lower-case, runs outside `[a-z0-9._-]` to `-`, leading and trailing `-` trimmed. */
54
+ const slugOf = (name) => {
55
+ if (name === void 0) return void 0;
56
+ const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
57
+ return slug.length > 0 ? slug : void 0;
58
+ };
59
+ const humanActorId = (identity, humans) => {
60
+ const local = localPartOf(identity.email);
61
+ const slug = slugOf(identity.name);
62
+ const candidates = [local, slug].filter((candidate) => candidate !== void 0).map((candidate) => candidate.toLowerCase());
63
+ for (const entry of humans) {
64
+ if (!entry.startsWith(HUMAN_PREFIX)) continue;
65
+ if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(entry);
66
+ }
67
+ if (local !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${local}`));
68
+ if (slug !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${slug}`));
69
+ return Option.none();
70
+ };
71
+ const staleAfter = (from, config) => DateTime.addDuration(from, config.lifecycle?.default_stale_after ?? OkfitConfig.DEFAULTS.lifecycle?.default_stale_after ?? FALLBACK_STALE_AFTER);
72
+ const committed = (entry, entries) => ({
73
+ _tag: "committed",
74
+ at: entry.authoredAt,
75
+ sha: entry.sha,
76
+ committedAt: entry.committedAt,
77
+ authorName: entry.authorName,
78
+ authorEmail: entry.authorEmail,
79
+ creating: entry === entries[entries.length - 1]
80
+ });
81
+ const uncommitted = (reason) => ({
82
+ _tag: "uncommitted",
83
+ reason
84
+ });
85
+ /**
86
+ * Spec 5.4 derivation rules for the `software-project` profile, package-global
87
+ * (P-38). Pure members take every input as an argument; effectful members
88
+ * require `Git`, `GitHistory` and, for the worktree read, `FileSystem` and
89
+ * `Path` (P-30). Never writes, never reads `generated.by` (P-40).
90
+ *
91
+ * @public
92
+ */
93
+ var Derivation = class {
94
+ constructor() {}
95
+ /**
96
+ * `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
97
+ * text removed (P-6). Pure. Applied to both sides of every comparison so `core.autocrlf` worktrees compare clean.
98
+ */
99
+ static body = body;
100
+ /**
101
+ * Lowercase 64-char hex sha256 of `Derivation.body(text)`, through `effect`'s platform-agnostic
102
+ * `Crypto` service (issue #19). Same normalization as `body`, so a frontmatter-only edit or a
103
+ * `core.autocrlf` worktree never changes the digest.
104
+ */
105
+ static bodyDigest = bodyDigest;
106
+ /**
107
+ * Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
108
+ * 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
109
+ * submodules and worktrees work because the question is asked from the file's directory (P-39).
110
+ * 2. `realPath` both (rev-parse is symlink-resolved), then the repo-relative posix path via `Path` (P-39).
111
+ * 3. `worktree = body(readFileString(file))`.
112
+ * 4. `Git.show(realRoot, "HEAD", rel)`: `UnknownRefError` is `uncommitted { unborn }` (P-11), `Option.none`
113
+ * is `uncommitted { untracked }` (P-47), a body differing from `worktree` is `uncommitted { dirty }` (P-9).
114
+ * The comparison is against HEAD's blob, never the newest log entry (P-46).
115
+ * 5. `entries = GitHistory.pathLog(realRoot, rel)` newest first (P-5, P-46); `[]` is `uncommitted { unborn }`.
116
+ * 6. One `Git.show` per older blob (P-8), newest first: the first entry whose body differs from the next
117
+ * older entry's body wins, else the oldest entry (the creating commit). "Previous" is the previous entry
118
+ * of the simplified `--follow` history, not the true parent (P-7). The newest entry's body is never
119
+ * re-fetched: `head.blob`, already confirmed equal to `worktree` by step 4, is reused (decision 56).
120
+ * `Option.none` for an older entry counts as "differs", never a defect (decision 56).
121
+ * `config` is reserved and ignored (P-48). Never writes, never reads `by` (P-40).
122
+ */
123
+ static generatedAt = Effect.fn("Derivation.generatedAt")(function* (options) {
124
+ const git = yield* Git;
125
+ const history = yield* GitHistory;
126
+ const fs = yield* FileSystem.FileSystem;
127
+ const path = yield* Path.Path;
128
+ const root = yield* git.repoRoot(path.dirname(options.file));
129
+ const realRoot = yield* fs.realPath(root);
130
+ const realFile = yield* fs.realPath(options.file);
131
+ const rel = path.relative(realRoot, realFile).split(path.sep).join("/");
132
+ const worktree = body(yield* fs.readFileString(options.file));
133
+ const head = yield* git.show(realRoot, "HEAD", rel).pipe(Effect.map((blob) => ({
134
+ unborn: false,
135
+ blob
136
+ })), Effect.catchTag("UnknownRefError", () => Effect.succeed({
137
+ unborn: true,
138
+ blob: Option.none()
139
+ })));
140
+ if (head.unborn) return uncommitted("unborn");
141
+ if (Option.isNone(head.blob)) return uncommitted("untracked");
142
+ if (body(head.blob.value) !== worktree) return uncommitted("dirty");
143
+ const entries = yield* history.pathLog(realRoot, rel);
144
+ const newest = entries[0];
145
+ if (newest === void 0) return uncommitted("unborn");
146
+ let current = worktree;
147
+ for (let index = 0; index < entries.length - 1; index += 1) {
148
+ const entry = entries[index];
149
+ const older = entries[index + 1];
150
+ if (entry === void 0 || older === void 0) break;
151
+ const olderBlob = yield* git.show(realRoot, older.sha, older.path);
152
+ if (Option.isNone(olderBlob)) return committed(entry, entries);
153
+ const olderBody = body(olderBlob.value);
154
+ if (olderBody !== current) return committed(entry, entries);
155
+ current = olderBody;
156
+ }
157
+ return committed(entries[entries.length - 1] ?? newest, entries);
158
+ });
159
+ /**
160
+ * P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
161
+ * and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
162
+ * candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
163
+ * The result is branded through core's `Actor` codec and always passes it.
164
+ */
165
+ static humanActorId = humanActorId;
166
+ /**
167
+ * Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
168
+ * (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
169
+ * scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
170
+ * Answers only "who is writing now" (P-40).
171
+ */
172
+ static generatedBy = Effect.fn("Derivation.generatedBy")(function* (options) {
173
+ if (options.writer === "agent") {
174
+ const agent = options.config.actors?.agent;
175
+ if (agent === void 0) return yield* new AgentActorUnconfiguredError({});
176
+ return agent;
177
+ }
178
+ const git = yield* Git;
179
+ const name = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.name"));
180
+ const email = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.email"));
181
+ const identity = {
182
+ ...name === void 0 ? {} : { name },
183
+ ...email === void 0 ? {} : { email }
184
+ };
185
+ const resolved = humanActorId(identity, options.config.actors?.humans ?? []);
186
+ if (Option.isNone(resolved)) return yield* new HumanActorUnresolvedError({
187
+ ...identity.name === void 0 ? {} : { userName: identity.name },
188
+ ...identity.email === void 0 ? {} : { userEmail: identity.email }
189
+ });
190
+ return resolved.value;
191
+ });
192
+ /**
193
+ * P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
194
+ * 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
195
+ */
196
+ static staleAfter = staleAfter;
197
+ };
198
+
199
+ //#endregion
200
+ export { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError };
package/GitHistory.js ADDED
@@ -0,0 +1,107 @@
1
+ import { Timestamp } from "@okfit/core";
2
+ import { Context, Effect, Layer, Schema } from "effect";
3
+ import { Git } from "@effected/git";
4
+
5
+ //#region src/GitHistory.ts
6
+ /**
7
+ * One record of the path log, newest first (P-3, P-5): the commit, both dates decoded through core's
8
+ * `Timestamp` (`%aI` / `%cI`, strict ISO with an offset; the `Type` is `DateTime.Utc`, P-4), the author
9
+ * identity, and the repository-relative posix path the blob had at that commit (the `--name-only` line).
10
+ * @public
11
+ */
12
+ var PathHistoryEntry = class extends Schema.Class("PathHistoryEntry")({
13
+ sha: Schema.String,
14
+ authoredAt: Timestamp,
15
+ committedAt: Timestamp,
16
+ authorName: Schema.String,
17
+ authorEmail: Schema.String,
18
+ path: Schema.String
19
+ }) {};
20
+ /**
21
+ * Profiles' own spawn failure, shaped like `@effected/git`'s `GitCommandError` (P-12). Raised by
22
+ * `GitHistory.layer` for any `GitCommandError` `Git.log` produces, including the one `Git.log`
23
+ * itself synthesizes when its own spawn timeout expires.
24
+ * @public
25
+ */
26
+ var GitHistoryError = class extends Schema.TaggedError()("GitHistoryError", {
27
+ /** The argv without the leading `git`. */
28
+ args: Schema.Array(Schema.String),
29
+ /** The working directory the command ran in. */
30
+ cwd: Schema.String,
31
+ /** git's exit code; absent when git never produced one. */
32
+ exitCode: Schema.optionalKey(Schema.Number),
33
+ /** git's stderr, captured under `LC_ALL=C`. */
34
+ stderr: Schema.String,
35
+ /** Spawn failure text, `timed out after 30s`, or `Git.log`'s own absorbed-failure detail. */
36
+ detail: Schema.optionalKey(Schema.String)
37
+ }) {
38
+ get message() {
39
+ const exit = this.exitCode === void 0 ? "" : ` (exit ${this.exitCode})`;
40
+ return `git ${this.args.join(" ")}${exit} in ${this.cwd}: ${this.detail ?? this.stderr}`;
41
+ }
42
+ };
43
+ const toGitHistoryError = (cwd, error) => new GitHistoryError({
44
+ args: error.args,
45
+ cwd,
46
+ ...error.exitCode === void 0 ? {} : { exitCode: error.exitCode },
47
+ stderr: error.stderr,
48
+ ...error.detail === void 0 ? {} : { detail: error.detail }
49
+ });
50
+ const make = (git) => ({ pathLog: (cwd, path, options) => Effect.gen(function* () {
51
+ return (yield* git.log(cwd, {
52
+ paths: [path],
53
+ follow: true,
54
+ firstParentDiffMerges: true,
55
+ ...options?.limit === void 0 ? {} : { limit: options.limit }
56
+ }).pipe(Effect.catchTag("GitCommandError", (error) => Effect.fail(toGitHistoryError(cwd, error))))).filter((entry) => entry.paths.length > 0).map((entry) => {
57
+ const [path] = entry.paths;
58
+ return PathHistoryEntry.make({
59
+ sha: entry.sha,
60
+ authoredAt: entry.authoredAt,
61
+ committedAt: entry.committedAt,
62
+ authorName: entry.authorName,
63
+ authorEmail: entry.authorEmail,
64
+ path: path ?? ""
65
+ });
66
+ });
67
+ }) });
68
+ const notScripted = (path) => Effect.die(/* @__PURE__ */ new Error(`GitHistory.makeTest: pathLog(${path}) was called but not scripted`));
69
+ /**
70
+ * A path-scoped, date-bearing log: `Git.log` reads the whole repository history, and this adapter scopes
71
+ * it to one path (`--follow`, `--diff-merges=first-parent`) and decodes it into `PathHistoryEntry`.
72
+ * Delegates to `Git.log` as of `@effected/git` 0.12.0 (P-1, spec deviation retired).
73
+ * @public
74
+ */
75
+ var GitHistory = class GitHistory extends Context.Service()("@okfit/profiles/GitHistory") {
76
+ /**
77
+ * Live layer (P-1, P-5, P-11): one `Git.log` call per `pathLog`, resolved once at construction like
78
+ * `Git.layer` itself, so every member's `R` is `never`. `Git.log` already degrades an unborn `HEAD` and
79
+ * an unmatched pathspec to the empty array and classifies `not a git repository` as `NotARepositoryError`
80
+ * (both pass through unchanged); every other failure becomes `GitHistoryError`, including the one
81
+ * `Git.log` itself produces when its own 30 s spawn timeout expires (`@effected/git`'s `GIT_TIMEOUT`
82
+ * ceiling, not this adapter's) — `Git.log` never lets a bare `Cause.TimeoutError` escape. The CLI
83
+ * provides `ChildProcessSpawner` through `NodeServices.layer` (P-30).
84
+ */
85
+ static layer = Layer.effect(GitHistory, Effect.gen(function* () {
86
+ const git = yield* Git;
87
+ return make(git);
88
+ })).pipe(Layer.provide(Git.layer));
89
+ /**
90
+ * Scripted double (P-32): responses keyed by the `path` argument exactly as passed to `pathLog`. A call
91
+ * whose path has no entry DIES with `GitHistory.makeTest: pathLog(<path>) was called but not scripted`,
92
+ * mirroring `Git.makeTest`; `limit` is honoured by slicing. Models none of the live semantics.
93
+ */
94
+ static makeTest = (script) => ({ pathLog: (_cwd, path, options) => {
95
+ if (!Object.hasOwn(script, path)) return notScripted(path);
96
+ const entries = script[path] ?? [];
97
+ return Effect.succeed(options?.limit === void 0 ? entries : entries.slice(0, options.limit));
98
+ } });
99
+ /**
100
+ * `Layer.succeed(GitHistory, GitHistory.makeTest(script))`. Mints a fresh layer per call: bind it to a
101
+ * `const`, since layers memoize by reference.
102
+ */
103
+ static layerTest = (script) => Layer.succeed(GitHistory, GitHistory.makeTest(script));
104
+ };
105
+
106
+ //#endregion
107
+ export { GitHistory, GitHistoryError, PathHistoryEntry, make };
package/Profile.js ADDED
@@ -0,0 +1,38 @@
1
+ import { DiagnosticRange, DiagnosticSeverity } from "@okfit/core";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/Profile.ts
5
+ /**
6
+ * Names of the profiles this package ships (P-38).
7
+ *
8
+ * @public
9
+ */
10
+ const PROFILE_NAMES = ["software-project"];
11
+ /**
12
+ * Profile-owned diagnostic codes (P-21). Core's `LintCode` is untouched; all
13
+ * three are severity `error` and are not configurable.
14
+ *
15
+ * @public
16
+ */
17
+ const ProfileDiagnosticCode = Schema.Literals([
18
+ "project-missing",
19
+ "project-multiple",
20
+ "project-not-at-root"
21
+ ]);
22
+ /**
23
+ * Same fields as core's `Diagnostic` with a profile code; a `Schema.Struct`
24
+ * per P-21/P-51. `file` is the bundle-relative posix path, `""` for the
25
+ * bundle-level `project-missing`. `range` is never set in phase 1.
26
+ *
27
+ * @public
28
+ */
29
+ const ProfileDiagnostic = Schema.Struct({
30
+ file: Schema.String,
31
+ range: Schema.optionalKey(DiagnosticRange),
32
+ code: ProfileDiagnosticCode,
33
+ severity: DiagnosticSeverity,
34
+ message: Schema.String
35
+ });
36
+
37
+ //#endregion
38
+ export { PROFILE_NAMES, ProfileDiagnostic, ProfileDiagnosticCode };
package/Profiles.js ADDED
@@ -0,0 +1,27 @@
1
+ import { softwareProject } from "./SoftwareProject.js";
2
+ import { Option } from "effect";
3
+
4
+ //#region src/Profiles.ts
5
+ const REGISTRY = /* @__PURE__ */ new Map([[softwareProject.name, softwareProject]]);
6
+ /**
7
+ * The profile registry (P-38). No third-party registration in phase 1; the
8
+ * `DEFAULTS < profile < file` merge is the CLI's (D-28).
9
+ *
10
+ * @public
11
+ */
12
+ var Profiles = class {
13
+ constructor() {}
14
+ /** The `software-project` profile (spec 5). */
15
+ static softwareProject = softwareProject;
16
+ /**
17
+ * `Option.none` for `"none"`, unknown names and anything not in
18
+ * `PROFILE_NAMES`; the CLI decides what that means (P-38). Exact match only.
19
+ */
20
+ static get = (name) => {
21
+ const found = REGISTRY.get(name);
22
+ return found === void 0 ? Option.none() : Option.some(found);
23
+ };
24
+ };
25
+
26
+ //#endregion
27
+ export { Profiles };
package/Provenance.js ADDED
@@ -0,0 +1,94 @@
1
+ import { Derivation } from "./Derivation.js";
2
+ import { Diagnostic, OkfitConfig, Timestamp } from "@okfit/core";
3
+ import { DateTime, Effect, Path, Schema } from "effect";
4
+
5
+ //#region src/Provenance.ts
6
+ const encodeAt = Schema.encodeSync(Timestamp);
7
+ /**
8
+ * A profiles-owned lint over committed provenance (S-8). NOT a `Profile`
9
+ * member and NOT a `ProfileDiagnostic` -- it returns core `Diagnostic`s with
10
+ * code `"generated-at-drift"`, a real `LintCode` member, so `severityFor`,
11
+ * the `[lint]` table, the renderers, the hook, and the MCP tool treat it
12
+ * like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
13
+ * are unchanged.
14
+ *
15
+ * Two tiers per concept (issue #19, body-digest design): when
16
+ * `generated.body_sha256` is present, drift is decided ENTIRELY by comparing it to
17
+ * `Derivation.bodyDigest` of the concept's current source -- no git call at
18
+ * all, so this works for a dirty worktree too (that is the point: an edited
19
+ * body is caught immediately, before it is even committed) and survives a
20
+ * squash/rebase merge that only rewrites commit dates, since the body
21
+ * itself, and therefore its digest, never changed. When `body_sha256` is
22
+ * absent (an un-migrated concept), this falls back to the original
23
+ * git-derived date comparison against `Derivation.generatedAt`, preserving
24
+ * `--skip-provenance`'s meaning for bundles that have not run `okfit sync`
25
+ * since this field was introduced.
26
+ *
27
+ * The tier-2 fallback returns `[]` the moment a `NotARepositoryError`
28
+ * surfaces: a bundle outside git has nothing to derive for any concept still
29
+ * on tier 2, so the first occurrence short-circuits the whole walk, not just
30
+ * the concept that triggered it (S-8). Emits no `range` (S-12, Judge note
31
+ * 7): `Diagnostic.range` is `Schema.optionalKey` and is simply omitted.
32
+ *
33
+ * `Provenance.lint` is total over severity (S-28, amending Judge note 3):
34
+ * when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
35
+ * `"off"`, it returns `[]` before any per-concept work and before any git
36
+ * call, so it is safe to call at any severity and never needs to cast
37
+ * `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
38
+ * calling at all; the two agree by construction (both skip the walk).
39
+ *
40
+ * A third, optional options argument carries `skipGitTier`: when `true`, a
41
+ * concept that falls through to tier 2 (no `generated.body_sha256`
42
+ * recorded) is skipped entirely -- no `Derivation.generatedAt` call, no
43
+ * git spawn at all -- while tier 1 still runs, unaffected, for every
44
+ * concept that DOES carry a digest. This is a runtime skip, not a
45
+ * type-level narrowing: the R channel still lists `Git | GitHistory` even
46
+ * when every call happens to skip them, since the type describes what the
47
+ * function CAN require, not what one particular call ends up using.
48
+ * Defaults to `false`, so every existing caller keeps its current
49
+ * behavior unchanged.
50
+ *
51
+ * @public
52
+ */
53
+ var Provenance = class {
54
+ constructor() {}
55
+ static lint = Effect.fn("Provenance.lint")(function* (bundle, config, options) {
56
+ const severity = OkfitConfig.severityFor(config, "generated-at-drift");
57
+ if (severity === "off") return [];
58
+ const skipGitTier = options?.skipGitTier === true;
59
+ const path = yield* Path.Path;
60
+ const diagnostics = [];
61
+ for (const [, concept] of bundle.concepts) {
62
+ if (concept.frontmatter.generated === void 0) continue;
63
+ const recordedDigest = concept.frontmatter.generated.body_sha256;
64
+ if (recordedDigest !== void 0) {
65
+ const currentDigest = yield* Derivation.bodyDigest(concept.document.source);
66
+ if (currentDigest === recordedDigest) continue;
67
+ diagnostics.push(Diagnostic.make({
68
+ file: concept.path,
69
+ code: "generated-at-drift",
70
+ severity,
71
+ message: `the body has changed since generated.at was last stamped: generated.body_sha256 is ${recordedDigest}, the current body hashes to ${currentDigest}`
72
+ }));
73
+ continue;
74
+ }
75
+ if (skipGitTier) continue;
76
+ const file = path.join(bundle.root, concept.path);
77
+ const derived = yield* Derivation.generatedAt({ file }).pipe(Effect.catchTag("NotARepositoryError", () => Effect.succeed(void 0)));
78
+ if (derived === void 0) return [];
79
+ if (derived._tag === "uncommitted") continue;
80
+ const recorded = concept.frontmatter.generated.at;
81
+ if (recorded !== void 0 && DateTime.Equivalence(recorded, derived.at)) continue;
82
+ diagnostics.push(Diagnostic.make({
83
+ file: concept.path,
84
+ code: "generated-at-drift",
85
+ severity,
86
+ message: `generated.at is ${recorded === void 0 ? "missing" : encodeAt(recorded)}; the last body change was ${encodeAt(derived.at)} (${derived.sha.slice(0, 7)})`
87
+ }));
88
+ }
89
+ return diagnostics;
90
+ });
91
+ };
92
+
93
+ //#endregion
94
+ export { Provenance };