@okfit/profiles 0.2.0 → 0.3.1
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/Derivation.js +11 -1
- package/Provenance.js +43 -8
- package/README.md +28 -13
- package/index.d.ts +38 -9
- package/package.json +5 -5
- package/tsdoc-metadata.json +1 -1
package/Derivation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { GitHistory } from "./GitHistory.js";
|
|
2
2
|
import { Actor, OkfitConfig } from "@okfit/core";
|
|
3
|
-
import { DateTime, Duration, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
+
import { Crypto, DateTime, Duration, Effect, Encoding, FileSystem, Option, Path, Schema } from "effect";
|
|
4
4
|
import { Git } from "@effected/git";
|
|
5
5
|
import { FrontmatterSource } from "@effected/markdown";
|
|
6
6
|
|
|
@@ -39,6 +39,10 @@ const FALLBACK_STALE_AFTER = Duration.days(90);
|
|
|
39
39
|
const decodeActor = Schema.decodeSync(Actor);
|
|
40
40
|
const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
|
|
41
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
|
+
});
|
|
42
46
|
/** The text before the first `@` (the whole value when there is none), or undefined when empty or containing whitespace. */
|
|
43
47
|
const localPartOf = (email) => {
|
|
44
48
|
if (email === void 0) return void 0;
|
|
@@ -94,6 +98,12 @@ var Derivation = class {
|
|
|
94
98
|
*/
|
|
95
99
|
static body = body;
|
|
96
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
|
+
/**
|
|
97
107
|
* Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
|
|
98
108
|
* 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
|
|
99
109
|
* submodules and worktrees work because the question is asked from the file's directory (P-39).
|
package/Provenance.js
CHANGED
|
@@ -12,13 +12,23 @@ const encodeAt = Schema.encodeSync(Timestamp);
|
|
|
12
12
|
* like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
|
|
13
13
|
* are unchanged.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
32
|
*
|
|
23
33
|
* `Provenance.lint` is total over severity (S-28, amending Judge note 3):
|
|
24
34
|
* when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
|
|
@@ -27,17 +37,42 @@ const encodeAt = Schema.encodeSync(Timestamp);
|
|
|
27
37
|
* `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
|
|
28
38
|
* calling at all; the two agree by construction (both skip the walk).
|
|
29
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
|
+
*
|
|
30
51
|
* @public
|
|
31
52
|
*/
|
|
32
53
|
var Provenance = class {
|
|
33
54
|
constructor() {}
|
|
34
|
-
static lint = Effect.fn("Provenance.lint")(function* (bundle, config) {
|
|
55
|
+
static lint = Effect.fn("Provenance.lint")(function* (bundle, config, options) {
|
|
35
56
|
const severity = OkfitConfig.severityFor(config, "generated-at-drift");
|
|
36
57
|
if (severity === "off") return [];
|
|
58
|
+
const skipGitTier = options?.skipGitTier === true;
|
|
37
59
|
const path = yield* Path.Path;
|
|
38
60
|
const diagnostics = [];
|
|
39
61
|
for (const [, concept] of bundle.concepts) {
|
|
40
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;
|
|
41
76
|
const file = path.join(bundle.root, concept.path);
|
|
42
77
|
const derived = yield* Derivation.generatedAt({ file }).pipe(Effect.catchTag("NotARepositoryError", () => Effect.succeed(void 0)));
|
|
43
78
|
if (derived === void 0) return [];
|
package/README.md
CHANGED
|
@@ -116,6 +116,7 @@ Each directory carries its own `index.md`. What `okfit init` writes into these f
|
|
|
116
116
|
- **`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.
|
|
117
117
|
- **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.
|
|
118
118
|
- **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.
|
|
119
|
+
- **The body digest answers "has the body changed", where dates cannot (issue #19).** `Derivation.bodyDigest(text)` is a sha256, hex-encoded, over the same normalised body `Derivation.body` produces (CRLF and lone CR to LF, trailing whitespace at end of text removed), so a `core.autocrlf` worktree and a trailing-newline change hash identically and a frontmatter-only edit does not move the digest at all. It is computed through effect's own `Crypto` service rather than `node:crypto`, so it carries `Crypto.Crypto` in its R channel; `NodeServices.layer` already supplies it wherever this package is used. `okfit sync` records it as `generated.body_sha256` — okfit's own extension key, not an OKF v0.2 field — and `Provenance.lint` compares it. Git cannot make this judgement on its own: the stamp lives in the same blob as the body, so a squash merge (one new commit carrying both) is indistinguishable by history from a body edited without a re-stamp.
|
|
119
120
|
- **Human actor resolution (P-13).** Given git identity `{ name?, email? }` and `config.actors.humans`, the first hit wins: (1) a `human:<id>` entry whose id matches the email's local part or the name's slug, case-insensitively (config spelling returned); (2) `human:<local part>`; (3) `human:<slug of name>`; (4) unresolved. The slug lowercases `name`, replaces runs of whitespace and characters outside `[A-Za-z0-9._-]` with `-`, and trims leading and trailing `-`.
|
|
120
121
|
- **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.
|
|
121
122
|
- **`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.
|
|
@@ -134,19 +135,33 @@ Frontmatter serialisation, the uncommitted policy's enforcement, `now`, layer co
|
|
|
134
135
|
|
|
135
136
|
## Provenance
|
|
136
137
|
|
|
137
|
-
`Provenance.lint(bundle, config)` is the `generated-at-drift`
|
|
138
|
-
(`lint.generated_at_drift`, config default `"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
138
|
+
`Provenance.lint(bundle, config, options?)` is the `generated-at-drift`
|
|
139
|
+
lint (`lint.generated_at_drift`, config default `"warn"`), and it runs in
|
|
140
|
+
two tiers (issue #19).
|
|
141
|
+
|
|
142
|
+
Tier 1 applies to a concept that records `generated.body_sha256`: the
|
|
143
|
+
recorded digest is compared against
|
|
144
|
+
`Derivation.bodyDigest(concept.document.source)`, a pure comparison over
|
|
145
|
+
text already in memory with no git call and no filesystem read. Drift here
|
|
146
|
+
means the body has changed since it was last stamped -- never that a squash
|
|
147
|
+
or rebase merge minted a new author date for the commit that carried it.
|
|
148
|
+
Because the comparison is against the worktree, a body edited but not yet
|
|
149
|
+
committed reports immediately.
|
|
150
|
+
|
|
151
|
+
Tier 2 applies to a concept stamped before the digest field existed: the
|
|
152
|
+
original walk, one `Derivation.generatedAt` per concept, reporting when a
|
|
153
|
+
committed body's `generated.at` is missing or is not the derived instant
|
|
154
|
+
(compared as decoded `DateTime.Utc` values, never encoded strings). Tier 2
|
|
155
|
+
is silent for an uncommitted body and returns `[]` the moment it discovers
|
|
156
|
+
the bundle is outside a git repository at all.
|
|
157
|
+
|
|
158
|
+
A concept with no `generated` block is skipped by both tiers. The lint
|
|
159
|
+
emits no `range` -- tier 1's message carries both digests and tier 2's
|
|
160
|
+
carries both instants and a 7-character sha, which is what a reader acts
|
|
161
|
+
on. It is a plain facade over `Derivation`, not a `Profile` member:
|
|
162
|
+
`severityFor`, the `[lint]` table, the renderers, the PostToolUse hook, and
|
|
163
|
+
the MCP `validate_bundle` tool all treat its `Diagnostic`s like every other
|
|
164
|
+
lint. It is total over severity
|
|
150
165
|
(S-28): when the resolved `generated-at-drift` severity is `"off"` it
|
|
151
166
|
returns `[]` before any per-concept work and before any git call, so it is
|
|
152
167
|
safe to call at any severity. The CLI (`okfit validate`, `okfit sync`'s own
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
|
|
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
3
|
import { Actor, Diagnostic, DiagnosticRange, LoadedBundle, OkfitConfig } from "@okfit/core";
|
|
4
4
|
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
@@ -239,6 +239,12 @@ export declare class Derivation {
|
|
|
239
239
|
* text removed (P-6). Pure. Applied to both sides of every comparison so `core.autocrlf` worktrees compare clean.
|
|
240
240
|
*/
|
|
241
241
|
static readonly body: (text: string) => string;
|
|
242
|
+
/**
|
|
243
|
+
* Lowercase 64-char hex sha256 of `Derivation.body(text)`, through `effect`'s platform-agnostic
|
|
244
|
+
* `Crypto` service (issue #19). Same normalization as `body`, so a frontmatter-only edit or a
|
|
245
|
+
* `core.autocrlf` worktree never changes the digest.
|
|
246
|
+
*/
|
|
247
|
+
static readonly bodyDigest: (text: string) => Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>;
|
|
242
248
|
/**
|
|
243
249
|
* Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
|
|
244
250
|
* 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
|
|
@@ -389,13 +395,23 @@ export declare class Profiles {
|
|
|
389
395
|
* like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
|
|
390
396
|
* are unchanged.
|
|
391
397
|
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* `
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
398
|
+
* Two tiers per concept (issue #19, body-digest design): when
|
|
399
|
+
* `generated.body_sha256` is present, drift is decided ENTIRELY by comparing it to
|
|
400
|
+
* `Derivation.bodyDigest` of the concept's current source -- no git call at
|
|
401
|
+
* all, so this works for a dirty worktree too (that is the point: an edited
|
|
402
|
+
* body is caught immediately, before it is even committed) and survives a
|
|
403
|
+
* squash/rebase merge that only rewrites commit dates, since the body
|
|
404
|
+
* itself, and therefore its digest, never changed. When `body_sha256` is
|
|
405
|
+
* absent (an un-migrated concept), this falls back to the original
|
|
406
|
+
* git-derived date comparison against `Derivation.generatedAt`, preserving
|
|
407
|
+
* `--skip-provenance`'s meaning for bundles that have not run `okfit sync`
|
|
408
|
+
* since this field was introduced.
|
|
409
|
+
*
|
|
410
|
+
* The tier-2 fallback returns `[]` the moment a `NotARepositoryError`
|
|
411
|
+
* surfaces: a bundle outside git has nothing to derive for any concept still
|
|
412
|
+
* on tier 2, so the first occurrence short-circuits the whole walk, not just
|
|
413
|
+
* the concept that triggered it (S-8). Emits no `range` (S-12, Judge note
|
|
414
|
+
* 7): `Diagnostic.range` is `Schema.optionalKey` and is simply omitted.
|
|
399
415
|
*
|
|
400
416
|
* `Provenance.lint` is total over severity (S-28, amending Judge note 3):
|
|
401
417
|
* when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
|
|
@@ -404,11 +420,24 @@ export declare class Profiles {
|
|
|
404
420
|
* `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
|
|
405
421
|
* calling at all; the two agree by construction (both skip the walk).
|
|
406
422
|
*
|
|
423
|
+
* A third, optional options argument carries `skipGitTier`: when `true`, a
|
|
424
|
+
* concept that falls through to tier 2 (no `generated.body_sha256`
|
|
425
|
+
* recorded) is skipped entirely -- no `Derivation.generatedAt` call, no
|
|
426
|
+
* git spawn at all -- while tier 1 still runs, unaffected, for every
|
|
427
|
+
* concept that DOES carry a digest. This is a runtime skip, not a
|
|
428
|
+
* type-level narrowing: the R channel still lists `Git | GitHistory` even
|
|
429
|
+
* when every call happens to skip them, since the type describes what the
|
|
430
|
+
* function CAN require, not what one particular call ends up using.
|
|
431
|
+
* Defaults to `false`, so every existing caller keeps its current
|
|
432
|
+
* behavior unchanged.
|
|
433
|
+
*
|
|
407
434
|
* @public
|
|
408
435
|
*/
|
|
409
436
|
export declare class Provenance {
|
|
410
437
|
private constructor();
|
|
411
|
-
static readonly lint: (bundle: LoadedBundle, config: OkfitConfig
|
|
438
|
+
static readonly lint: (bundle: LoadedBundle, config: OkfitConfig, options?: {
|
|
439
|
+
readonly skipGitTier?: boolean;
|
|
440
|
+
}) => Effect.Effect<ReadonlyArray<Diagnostic>, GitHistoryError | GitCommandError | UnknownRefError | PlatformError.PlatformError, Git | GitHistory | FileSystem.FileSystem | Path.Path | Crypto.Crypto>;
|
|
412
441
|
}
|
|
413
442
|
//#endregion
|
|
414
443
|
export type { GeneratedAtError, GeneratedAtOptions, GeneratedByError, GeneratedByOptions, GitHistoryShape, GitIdentity, Layout, LayoutDirectory, PathLogOptions, Profile, ProfileName, UncommittedReason, Writer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okfit/profiles",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Named OKF configuration profiles for okfit, starting with the software-project profile.",
|
|
6
6
|
"keywords": [
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"./package.json": "./package.json"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@effected/git": "^0.
|
|
39
|
-
"@effected/markdown": "^0.
|
|
40
|
-
"@okfit/core": "^0.
|
|
41
|
-
"effect": "4.0.0-rc.
|
|
38
|
+
"@effected/git": "^0.15.0",
|
|
39
|
+
"@effected/markdown": "^0.10.0",
|
|
40
|
+
"@okfit/core": "^0.3.1",
|
|
41
|
+
"effect": "4.0.0-rc.115"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=24.11.0"
|