@okfit/profiles 0.1.0 → 0.2.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/BodyProvenance.js +44 -0
- package/Derivation.js +190 -0
- package/GitHistory.js +107 -0
- package/Profile.js +38 -0
- package/Profiles.js +27 -0
- package/Provenance.js +59 -0
- package/README.md +148 -3
- package/SoftwareProject.js +170 -0
- package/index.d.ts +400 -6
- package/index.js +7 -14
- package/package.json +6 -4
|
@@ -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,190 @@
|
|
|
1
|
+
import { GitHistory } from "./GitHistory.js";
|
|
2
|
+
import { Actor, OkfitConfig } from "@okfit/core";
|
|
3
|
+
import { DateTime, Duration, Effect, 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
|
+
/** The text before the first `@` (the whole value when there is none), or undefined when empty or containing whitespace. */
|
|
43
|
+
const localPartOf = (email) => {
|
|
44
|
+
if (email === void 0) return void 0;
|
|
45
|
+
const at = email.indexOf("@");
|
|
46
|
+
const local = at === -1 ? email : email.slice(0, at);
|
|
47
|
+
return local.length > 0 && !/\s/.test(local) ? local : void 0;
|
|
48
|
+
};
|
|
49
|
+
/** P-13 step 3: lower-case, runs outside `[a-z0-9._-]` to `-`, leading and trailing `-` trimmed. */
|
|
50
|
+
const slugOf = (name) => {
|
|
51
|
+
if (name === void 0) return void 0;
|
|
52
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
53
|
+
return slug.length > 0 ? slug : void 0;
|
|
54
|
+
};
|
|
55
|
+
const humanActorId = (identity, humans) => {
|
|
56
|
+
const local = localPartOf(identity.email);
|
|
57
|
+
const slug = slugOf(identity.name);
|
|
58
|
+
const candidates = [local, slug].filter((candidate) => candidate !== void 0).map((candidate) => candidate.toLowerCase());
|
|
59
|
+
for (const entry of humans) {
|
|
60
|
+
if (!entry.startsWith(HUMAN_PREFIX)) continue;
|
|
61
|
+
if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(entry);
|
|
62
|
+
}
|
|
63
|
+
if (local !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${local}`));
|
|
64
|
+
if (slug !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${slug}`));
|
|
65
|
+
return Option.none();
|
|
66
|
+
};
|
|
67
|
+
const staleAfter = (from, config) => DateTime.addDuration(from, config.lifecycle?.default_stale_after ?? OkfitConfig.DEFAULTS.lifecycle?.default_stale_after ?? FALLBACK_STALE_AFTER);
|
|
68
|
+
const committed = (entry, entries) => ({
|
|
69
|
+
_tag: "committed",
|
|
70
|
+
at: entry.authoredAt,
|
|
71
|
+
sha: entry.sha,
|
|
72
|
+
committedAt: entry.committedAt,
|
|
73
|
+
authorName: entry.authorName,
|
|
74
|
+
authorEmail: entry.authorEmail,
|
|
75
|
+
creating: entry === entries[entries.length - 1]
|
|
76
|
+
});
|
|
77
|
+
const uncommitted = (reason) => ({
|
|
78
|
+
_tag: "uncommitted",
|
|
79
|
+
reason
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* Spec 5.4 derivation rules for the `software-project` profile, package-global
|
|
83
|
+
* (P-38). Pure members take every input as an argument; effectful members
|
|
84
|
+
* require `Git`, `GitHistory` and, for the worktree read, `FileSystem` and
|
|
85
|
+
* `Path` (P-30). Never writes, never reads `generated.by` (P-40).
|
|
86
|
+
*
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
var Derivation = class {
|
|
90
|
+
constructor() {}
|
|
91
|
+
/**
|
|
92
|
+
* `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
|
|
93
|
+
* text removed (P-6). Pure. Applied to both sides of every comparison so `core.autocrlf` worktrees compare clean.
|
|
94
|
+
*/
|
|
95
|
+
static body = body;
|
|
96
|
+
/**
|
|
97
|
+
* Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
|
|
98
|
+
* 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
|
|
99
|
+
* submodules and worktrees work because the question is asked from the file's directory (P-39).
|
|
100
|
+
* 2. `realPath` both (rev-parse is symlink-resolved), then the repo-relative posix path via `Path` (P-39).
|
|
101
|
+
* 3. `worktree = body(readFileString(file))`.
|
|
102
|
+
* 4. `Git.show(realRoot, "HEAD", rel)`: `UnknownRefError` is `uncommitted { unborn }` (P-11), `Option.none`
|
|
103
|
+
* is `uncommitted { untracked }` (P-47), a body differing from `worktree` is `uncommitted { dirty }` (P-9).
|
|
104
|
+
* The comparison is against HEAD's blob, never the newest log entry (P-46).
|
|
105
|
+
* 5. `entries = GitHistory.pathLog(realRoot, rel)` newest first (P-5, P-46); `[]` is `uncommitted { unborn }`.
|
|
106
|
+
* 6. One `Git.show` per older blob (P-8), newest first: the first entry whose body differs from the next
|
|
107
|
+
* older entry's body wins, else the oldest entry (the creating commit). "Previous" is the previous entry
|
|
108
|
+
* of the simplified `--follow` history, not the true parent (P-7). The newest entry's body is never
|
|
109
|
+
* re-fetched: `head.blob`, already confirmed equal to `worktree` by step 4, is reused (decision 56).
|
|
110
|
+
* `Option.none` for an older entry counts as "differs", never a defect (decision 56).
|
|
111
|
+
* `config` is reserved and ignored (P-48). Never writes, never reads `by` (P-40).
|
|
112
|
+
*/
|
|
113
|
+
static generatedAt = Effect.fn("Derivation.generatedAt")(function* (options) {
|
|
114
|
+
const git = yield* Git;
|
|
115
|
+
const history = yield* GitHistory;
|
|
116
|
+
const fs = yield* FileSystem.FileSystem;
|
|
117
|
+
const path = yield* Path.Path;
|
|
118
|
+
const root = yield* git.repoRoot(path.dirname(options.file));
|
|
119
|
+
const realRoot = yield* fs.realPath(root);
|
|
120
|
+
const realFile = yield* fs.realPath(options.file);
|
|
121
|
+
const rel = path.relative(realRoot, realFile).split(path.sep).join("/");
|
|
122
|
+
const worktree = body(yield* fs.readFileString(options.file));
|
|
123
|
+
const head = yield* git.show(realRoot, "HEAD", rel).pipe(Effect.map((blob) => ({
|
|
124
|
+
unborn: false,
|
|
125
|
+
blob
|
|
126
|
+
})), Effect.catchTag("UnknownRefError", () => Effect.succeed({
|
|
127
|
+
unborn: true,
|
|
128
|
+
blob: Option.none()
|
|
129
|
+
})));
|
|
130
|
+
if (head.unborn) return uncommitted("unborn");
|
|
131
|
+
if (Option.isNone(head.blob)) return uncommitted("untracked");
|
|
132
|
+
if (body(head.blob.value) !== worktree) return uncommitted("dirty");
|
|
133
|
+
const entries = yield* history.pathLog(realRoot, rel);
|
|
134
|
+
const newest = entries[0];
|
|
135
|
+
if (newest === void 0) return uncommitted("unborn");
|
|
136
|
+
let current = worktree;
|
|
137
|
+
for (let index = 0; index < entries.length - 1; index += 1) {
|
|
138
|
+
const entry = entries[index];
|
|
139
|
+
const older = entries[index + 1];
|
|
140
|
+
if (entry === void 0 || older === void 0) break;
|
|
141
|
+
const olderBlob = yield* git.show(realRoot, older.sha, older.path);
|
|
142
|
+
if (Option.isNone(olderBlob)) return committed(entry, entries);
|
|
143
|
+
const olderBody = body(olderBlob.value);
|
|
144
|
+
if (olderBody !== current) return committed(entry, entries);
|
|
145
|
+
current = olderBody;
|
|
146
|
+
}
|
|
147
|
+
return committed(entries[entries.length - 1] ?? newest, entries);
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
150
|
+
* P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
|
|
151
|
+
* and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
|
|
152
|
+
* candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
|
|
153
|
+
* The result is branded through core's `Actor` codec and always passes it.
|
|
154
|
+
*/
|
|
155
|
+
static humanActorId = humanActorId;
|
|
156
|
+
/**
|
|
157
|
+
* Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
|
|
158
|
+
* (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
|
|
159
|
+
* scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
|
|
160
|
+
* Answers only "who is writing now" (P-40).
|
|
161
|
+
*/
|
|
162
|
+
static generatedBy = Effect.fn("Derivation.generatedBy")(function* (options) {
|
|
163
|
+
if (options.writer === "agent") {
|
|
164
|
+
const agent = options.config.actors?.agent;
|
|
165
|
+
if (agent === void 0) return yield* new AgentActorUnconfiguredError({});
|
|
166
|
+
return agent;
|
|
167
|
+
}
|
|
168
|
+
const git = yield* Git;
|
|
169
|
+
const name = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.name"));
|
|
170
|
+
const email = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.email"));
|
|
171
|
+
const identity = {
|
|
172
|
+
...name === void 0 ? {} : { name },
|
|
173
|
+
...email === void 0 ? {} : { email }
|
|
174
|
+
};
|
|
175
|
+
const resolved = humanActorId(identity, options.config.actors?.humans ?? []);
|
|
176
|
+
if (Option.isNone(resolved)) return yield* new HumanActorUnresolvedError({
|
|
177
|
+
...identity.name === void 0 ? {} : { userName: identity.name },
|
|
178
|
+
...identity.email === void 0 ? {} : { userEmail: identity.email }
|
|
179
|
+
});
|
|
180
|
+
return resolved.value;
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
* P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
|
|
184
|
+
* 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
|
|
185
|
+
*/
|
|
186
|
+
static staleAfter = staleAfter;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
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,59 @@
|
|
|
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
|
+
* Runs `Derivation.generatedAt` per concept, skips uncommitted bodies and
|
|
16
|
+
* concepts without a `generated` block, and returns `[]` the moment a
|
|
17
|
+
* `NotARepositoryError` surfaces: a bundle outside git has nothing to
|
|
18
|
+
* derive for any concept, so the first occurrence short-circuits the whole
|
|
19
|
+
* walk, not just the concept that triggered it (S-8). Emits no `range`
|
|
20
|
+
* (S-12, Judge note 7): `Diagnostic.range` is `Schema.optionalKey` and is
|
|
21
|
+
* simply omitted.
|
|
22
|
+
*
|
|
23
|
+
* `Provenance.lint` is total over severity (S-28, amending Judge note 3):
|
|
24
|
+
* when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
|
|
25
|
+
* `"off"`, it returns `[]` before any per-concept work and before any git
|
|
26
|
+
* call, so it is safe to call at any severity and never needs to cast
|
|
27
|
+
* `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
|
|
28
|
+
* calling at all; the two agree by construction (both skip the walk).
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
var Provenance = class {
|
|
33
|
+
constructor() {}
|
|
34
|
+
static lint = Effect.fn("Provenance.lint")(function* (bundle, config) {
|
|
35
|
+
const severity = OkfitConfig.severityFor(config, "generated-at-drift");
|
|
36
|
+
if (severity === "off") return [];
|
|
37
|
+
const path = yield* Path.Path;
|
|
38
|
+
const diagnostics = [];
|
|
39
|
+
for (const [, concept] of bundle.concepts) {
|
|
40
|
+
if (concept.frontmatter.generated === void 0) continue;
|
|
41
|
+
const file = path.join(bundle.root, concept.path);
|
|
42
|
+
const derived = yield* Derivation.generatedAt({ file }).pipe(Effect.catchTag("NotARepositoryError", () => Effect.succeed(void 0)));
|
|
43
|
+
if (derived === void 0) return [];
|
|
44
|
+
if (derived._tag === "uncommitted") continue;
|
|
45
|
+
const recorded = concept.frontmatter.generated.at;
|
|
46
|
+
if (recorded !== void 0 && DateTime.Equivalence(recorded, derived.at)) continue;
|
|
47
|
+
diagnostics.push(Diagnostic.make({
|
|
48
|
+
file: concept.path,
|
|
49
|
+
code: "generated-at-drift",
|
|
50
|
+
severity,
|
|
51
|
+
message: `generated.at is ${recorded === void 0 ? "missing" : encodeAt(recorded)}; the last body change was ${encodeAt(derived.at)} (${derived.sha.slice(0, 7)})`
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
return diagnostics;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
export { Provenance };
|
package/README.md
CHANGED
|
@@ -1,12 +1,157 @@
|
|
|
1
1
|
# @okfit/profiles
|
|
2
2
|
|
|
3
|
-
Named configuration profiles for [okfit](https://github.com/spencerbeggs/okfit). A profile is a complete
|
|
3
|
+
Named configuration profiles for [okfit](https://github.com/spencerbeggs/okfit). A profile is a complete `OkfitConfig` value plus derivation rules for `generated.at` and `generated.by`. The first profile, `software-project`, describes a software repository's knowledge bundle.
|
|
4
4
|
|
|
5
5
|
> **Pre-release.** Part of the `@okfit/*` kit, in early development.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## The `software-project` profile
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`Profiles.softwareProject.config` is a hand-authored `OkfitConfig` (P-25) that sets only `concepts`, `types`, `tags`, and `extensions: {}`; `okf_version`, `bundle`, `lifecycle`, `actors`, and `lint` are inherited from `OkfitConfig.DEFAULTS` by whoever merges it (the CLI applies `DEFAULTS < profile < file` with `OkfitConfig.merge`). `Profiles.get(name)` resolves a profile by its exact name and answers `Option.none` for `"none"` and unknown names. The TOML below decodes to the exact same value and is checked against the literal by `__test__/SoftwareProject.test.ts`:
|
|
10
|
+
|
|
11
|
+
```toml
|
|
12
|
+
[concepts]
|
|
13
|
+
required = ["title", "description"]
|
|
14
|
+
tags.required = []
|
|
15
|
+
|
|
16
|
+
[types.Project]
|
|
17
|
+
description = "The repository's root concept: its purpose, boundaries, and non-goals."
|
|
18
|
+
guidance = "Exactly one Project exists and it lives at the bundle root as the project file. State the purpose in one paragraph and list what is deliberately out of scope so a reader never infers boundaries from silence."
|
|
19
|
+
|
|
20
|
+
[types.Module]
|
|
21
|
+
description = "A unit of code with an owner and a boundary."
|
|
22
|
+
guidance = "One per workspace package, plugin, website, or action. Link to the Decisions that shaped it and the Conventions it is bound by."
|
|
23
|
+
required = ["resource", "kind"]
|
|
24
|
+
|
|
25
|
+
[types.Module.fields.kind]
|
|
26
|
+
description = "What sort of unit this is; drives which conventions apply."
|
|
27
|
+
values.workspace = "The monorepo root: tooling, CI, release, shared config."
|
|
28
|
+
values.package = "A publishable npm package under packages/."
|
|
29
|
+
values.website = "A docs or marketing site, usually RSPress."
|
|
30
|
+
values.plugin = "A Claude Code or editor plugin distributed outside npm."
|
|
31
|
+
values.action = "A GitHub Action."
|
|
32
|
+
|
|
33
|
+
[types.Module.fields.resource]
|
|
34
|
+
description = "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core."
|
|
35
|
+
kind = "path"
|
|
36
|
+
|
|
37
|
+
[types.Decision]
|
|
38
|
+
description = "A choice made, the alternatives rejected, and why."
|
|
39
|
+
guidance = "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it."
|
|
40
|
+
require_verified = true
|
|
41
|
+
|
|
42
|
+
[types.Decision.fields.supersedes]
|
|
43
|
+
description = "The Decision this one replaces."
|
|
44
|
+
kind = "path"
|
|
45
|
+
|
|
46
|
+
[types.Convention]
|
|
47
|
+
description = "A rule contributors and agents must follow."
|
|
48
|
+
guidance = "State the rule as an instruction rather than a description of current behaviour. Give it a staleness window so it is re-examined on a cadence instead of rotting silently."
|
|
49
|
+
|
|
50
|
+
[types.Interface]
|
|
51
|
+
description = "A contract others depend on."
|
|
52
|
+
guidance = "Document the contract from the consumer's side: what stays stable, not how it is built. Point resource at the file, endpoint, or schema the promise lives in."
|
|
53
|
+
required = ["kind"]
|
|
54
|
+
|
|
55
|
+
[types.Interface.fields.kind]
|
|
56
|
+
description = "What kind of contract this is."
|
|
57
|
+
values.api = "A programmatic library surface: exported functions, classes, and types."
|
|
58
|
+
values.cli = "A command-line interface: commands, flags, and exit codes."
|
|
59
|
+
values.config = "A configuration file schema other tools read or write."
|
|
60
|
+
values.wire = "A network or IPC wire format."
|
|
61
|
+
values.mcp = "An MCP tool or resource surface."
|
|
62
|
+
|
|
63
|
+
[types.Interface.fields.resource]
|
|
64
|
+
description = "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core/src/index.ts."
|
|
65
|
+
kind = "path"
|
|
66
|
+
|
|
67
|
+
[types.Reference]
|
|
68
|
+
description = "Mirrored external material kept under the references directory."
|
|
69
|
+
guidance = "Only for material this repository must cite reliably even if the original moves. Every Reference declares where it came from in sources."
|
|
70
|
+
required = ["sources"]
|
|
71
|
+
|
|
72
|
+
[tags.architecture]
|
|
73
|
+
description = "Concerns the shape of the system rather than one module."
|
|
74
|
+
|
|
75
|
+
[tags.testing]
|
|
76
|
+
description = "Concerns how the system is verified: strategy, fixtures, and coverage policy."
|
|
77
|
+
|
|
78
|
+
[tags.release]
|
|
79
|
+
description = "Concerns how changes ship: versioning, changelogs, publishing, and tagging."
|
|
80
|
+
|
|
81
|
+
[tags.security]
|
|
82
|
+
description = "Concerns trust boundaries, secrets, permissions, or attack surface."
|
|
83
|
+
|
|
84
|
+
[tags.performance]
|
|
85
|
+
description = "Concerns speed, memory, or resource cost and the trade-offs made for them."
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Only `concepts`, `types`, and `tags` carry real vocabulary; the literal's `extensions: {}` is `OkfitConfig`'s one required key and never appears on disk, since the codec folds unknown top-level keys into it (P-24: `fields.<k>.kind = "path"` and the `tags` table are vocabulary only — core enforces none of it yet). `Reference.required = ["sources"]` checks presence only: `sources = []` passes, and each entry's shape is core's `family-invalid` (P-23).
|
|
89
|
+
|
|
90
|
+
`Profiles.softwareProject.check(bundle)` additionally enforces, outside `OkfitConfig` entirely, that exactly one `Project` concept exists and lives at the bundle root (P-21): zero `Project`s is `project-missing`, more than one is `project-multiple` (one diagnostic per `Project` concept), and a `Project` whose path contains `/` is `project-not-at-root`. All three are severity `error` and are not configurable.
|
|
91
|
+
|
|
92
|
+
## Layout
|
|
93
|
+
|
|
94
|
+
`Profiles.softwareProject.layout` is scaffolding data for `okfit init`, never part of `OkfitConfig` (P-26):
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
root:
|
|
98
|
+
index.md -- bundle index
|
|
99
|
+
log.md -- bundle log
|
|
100
|
+
project.md -- the one Project concept, at the bundle root
|
|
101
|
+
|
|
102
|
+
directories:
|
|
103
|
+
modules/ -> Module
|
|
104
|
+
decisions/ -> Decision
|
|
105
|
+
conventions/ -> Convention
|
|
106
|
+
interfaces/ -> Interface
|
|
107
|
+
references/ -> Reference
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Each directory carries its own `index.md`. What `okfit init` writes into these files is the CLI plan's concern, not this package's.
|
|
111
|
+
|
|
112
|
+
## Derivation
|
|
113
|
+
|
|
114
|
+
`Derivation` is package-global, not per profile:
|
|
115
|
+
|
|
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
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **`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.
|
|
122
|
+
|
|
123
|
+
Recommended stamping policy, followed end to end:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const by = yield* Derivation.generatedBy({ writer, cwd: path.dirname(file), config }); // P-16; agent needs actors.agent (P-17)
|
|
127
|
+
const provenance = yield* Derivation.generatedAt({ file, config }); // P-2, P-9, P-39
|
|
128
|
+
const at = provenance._tag === "committed" ? Option.some(provenance.at) : Option.none(); // omit `at` until committed (P-10)
|
|
129
|
+
const staleAfter = Derivation.staleAfter(Option.getOrElse(at, () => now), config); // P-19; `now` is the caller's
|
|
130
|
+
// serialisation is the CLI's: Schema.encodeSync(Timestamp) writes "...T08:00:00Z" after the P-18 core patch (P-41)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Frontmatter serialisation, the uncommitted policy's enforcement, `now`, layer composition (`Layer.mergeAll(Git.layer, GitHistory.layer).pipe(Layer.provideMerge(NodeServices.layer))`), exit codes, and everything `okfit init` writes belong to the CLI plan, not this package (P-30, P-41, P-44).
|
|
134
|
+
|
|
135
|
+
## Provenance
|
|
136
|
+
|
|
137
|
+
`Provenance.lint(bundle, config)` is the `generated-at-drift` lint
|
|
138
|
+
(`lint.generated_at_drift`, config default `"info"`): it walks
|
|
139
|
+
`Derivation.generatedAt` once per concept with a `generated` block, and
|
|
140
|
+
reports a core `Diagnostic` when a committed body's `generated.at` is
|
|
141
|
+
missing or does not match the derived instant (compared as decoded
|
|
142
|
+
`DateTime.Utc` values, never encoded strings). It is silent for an
|
|
143
|
+
uncommitted body, for a concept with no `generated` block, and returns `[]`
|
|
144
|
+
the moment it discovers the bundle is outside a git repository at all. It
|
|
145
|
+
emits no `range` — the message carries both instants and a 7-character
|
|
146
|
+
sha, which is what a reader acts on. It is a plain facade over
|
|
147
|
+
`Derivation`, not a `Profile` member: `severityFor`, the `[lint]` table,
|
|
148
|
+
the renderers, the PostToolUse hook, and the MCP `validate_bundle` tool all
|
|
149
|
+
treat its `Diagnostic`s like every other lint. It is total over severity
|
|
150
|
+
(S-28): when the resolved `generated-at-drift` severity is `"off"` it
|
|
151
|
+
returns `[]` before any per-concept work and before any git call, so it is
|
|
152
|
+
safe to call at any severity. The CLI (`okfit validate`, `okfit sync`'s own
|
|
153
|
+
reporting) also gates on `"off"` before calling at all; the two agree by
|
|
154
|
+
construction.
|
|
10
155
|
|
|
11
156
|
## License
|
|
12
157
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
//#region src/SoftwareProject.ts
|
|
2
|
+
/**
|
|
3
|
+
* Hand-authored, typed `OkfitConfig` (P-25). Sets ONLY `concepts`, `types`,
|
|
4
|
+
* `tags` and `extensions: {}`; `okf_version`, `bundle`, `lifecycle`, `actors`
|
|
5
|
+
* and `lint` are inherited from `OkfitConfig.DEFAULTS` by whoever merges.
|
|
6
|
+
* `actors.agent` is deliberately unset (P-17): which agent writes is a fact
|
|
7
|
+
* about the repository, not about the software-project shape. Every string
|
|
8
|
+
* obeys P-42 (one-sentence descriptions, at most two-sentence guidance, plain
|
|
9
|
+
* text). No explicit `required = []` anywhere (P-20). The literal type-checks
|
|
10
|
+
* as `OkfitConfig` without a decode step because no branded `Actor` is set
|
|
11
|
+
* (`CORE/OkfitConfig.ts:172`; `OkfitConfig.test.ts:102-107` precedent).
|
|
12
|
+
*/
|
|
13
|
+
const config = {
|
|
14
|
+
concepts: {
|
|
15
|
+
required: ["title", "description"],
|
|
16
|
+
tags: { required: [] }
|
|
17
|
+
},
|
|
18
|
+
types: {
|
|
19
|
+
Project: {
|
|
20
|
+
description: "The repository's root concept: its purpose, boundaries, and non-goals.",
|
|
21
|
+
guidance: "Exactly one Project exists and it lives at the bundle root as the project file. State the purpose in one paragraph and list what is deliberately out of scope so a reader never infers boundaries from silence."
|
|
22
|
+
},
|
|
23
|
+
Module: {
|
|
24
|
+
description: "A unit of code with an owner and a boundary.",
|
|
25
|
+
guidance: "One per workspace package, plugin, website, or action. Link to the Decisions that shaped it and the Conventions it is bound by.",
|
|
26
|
+
required: ["resource", "kind"],
|
|
27
|
+
fields: {
|
|
28
|
+
kind: {
|
|
29
|
+
description: "What sort of unit this is; drives which conventions apply.",
|
|
30
|
+
values: {
|
|
31
|
+
workspace: "The monorepo root: tooling, CI, release, shared config.",
|
|
32
|
+
package: "A publishable npm package under packages/.",
|
|
33
|
+
website: "A docs or marketing site, usually RSPress.",
|
|
34
|
+
plugin: "A Claude Code or editor plugin distributed outside npm.",
|
|
35
|
+
action: "A GitHub Action."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
resource: {
|
|
39
|
+
description: "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core.",
|
|
40
|
+
kind: "path"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
Decision: {
|
|
45
|
+
description: "A choice made, the alternatives rejected, and why.",
|
|
46
|
+
guidance: "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it.",
|
|
47
|
+
require_verified: true,
|
|
48
|
+
fields: { supersedes: {
|
|
49
|
+
description: "The Decision this one replaces.",
|
|
50
|
+
kind: "path"
|
|
51
|
+
} }
|
|
52
|
+
},
|
|
53
|
+
Convention: {
|
|
54
|
+
description: "A rule contributors and agents must follow.",
|
|
55
|
+
guidance: "State the rule as an instruction rather than a description of current behaviour. Give it a staleness window so it is re-examined on a cadence instead of rotting silently."
|
|
56
|
+
},
|
|
57
|
+
Interface: {
|
|
58
|
+
description: "A contract others depend on.",
|
|
59
|
+
guidance: "Document the contract from the consumer's side: what stays stable, not how it is built. Point resource at the file, endpoint, or schema the promise lives in.",
|
|
60
|
+
required: ["kind"],
|
|
61
|
+
fields: {
|
|
62
|
+
kind: {
|
|
63
|
+
description: "What kind of contract this is.",
|
|
64
|
+
values: {
|
|
65
|
+
api: "A programmatic library surface: exported functions, classes, and types.",
|
|
66
|
+
cli: "A command-line interface: commands, flags, and exit codes.",
|
|
67
|
+
config: "A configuration file schema other tools read or write.",
|
|
68
|
+
wire: "A network or IPC wire format.",
|
|
69
|
+
mcp: "An MCP tool or resource surface."
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
resource: {
|
|
73
|
+
description: "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core/src/index.ts.",
|
|
74
|
+
kind: "path"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
Reference: {
|
|
79
|
+
description: "Mirrored external material kept under the references directory.",
|
|
80
|
+
guidance: "Only for material this repository must cite reliably even if the original moves. Every Reference declares where it came from in sources.",
|
|
81
|
+
required: ["sources"]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
tags: {
|
|
85
|
+
architecture: { description: "Concerns the shape of the system rather than one module." },
|
|
86
|
+
testing: { description: "Concerns how the system is verified: strategy, fixtures, and coverage policy." },
|
|
87
|
+
release: { description: "Concerns how changes ship: versioning, changelogs, publishing, and tagging." },
|
|
88
|
+
security: { description: "Concerns trust boundaries, secrets, permissions, or attack surface." },
|
|
89
|
+
performance: { description: "Concerns speed, memory, or resource cost and the trade-offs made for them." }
|
|
90
|
+
},
|
|
91
|
+
extensions: {}
|
|
92
|
+
};
|
|
93
|
+
/** Spec 5.2 as data (P-26); consumed by `okfit init`, never by validation. */
|
|
94
|
+
const layout = {
|
|
95
|
+
root: {
|
|
96
|
+
index: "index.md",
|
|
97
|
+
log: "log.md",
|
|
98
|
+
project: "project.md"
|
|
99
|
+
},
|
|
100
|
+
directories: [
|
|
101
|
+
{
|
|
102
|
+
directory: "modules",
|
|
103
|
+
type: "Module"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
directory: "decisions",
|
|
107
|
+
type: "Decision"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
directory: "conventions",
|
|
111
|
+
type: "Convention"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
directory: "interfaces",
|
|
115
|
+
type: "Interface"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
directory: "references",
|
|
119
|
+
type: "Reference"
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
};
|
|
123
|
+
const PROJECT_TYPE = "Project";
|
|
124
|
+
const diagnostic = (file, code, message) => ({
|
|
125
|
+
file,
|
|
126
|
+
code,
|
|
127
|
+
severity: "error",
|
|
128
|
+
message
|
|
129
|
+
});
|
|
130
|
+
/** Plain code-unit order on `file` then `code` (decision 6); never locale-dependent. */
|
|
131
|
+
const compare = (a, b) => {
|
|
132
|
+
if (a.file < b.file) return -1;
|
|
133
|
+
if (a.file > b.file) return 1;
|
|
134
|
+
if (a.code < b.code) return -1;
|
|
135
|
+
if (a.code > b.code) return 1;
|
|
136
|
+
return 0;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Exactly one `Project`, at the bundle root (P-21). Pure over `bundle.concepts`
|
|
140
|
+
* (`CORE/Bundle.ts:98`; `Concept.type` `CORE/Concept.ts:27`). The rules are
|
|
141
|
+
* independent: two Projects with one nested yield three diagnostics. The file
|
|
142
|
+
* name `project.md` is layout guidance, not checked. Severity is always
|
|
143
|
+
* `error`; no `range`. Output is sorted by `file` then `code`.
|
|
144
|
+
*/
|
|
145
|
+
const check = (bundle) => {
|
|
146
|
+
const projects = [...bundle.concepts.values()].filter((concept) => concept.frontmatter.type === PROJECT_TYPE);
|
|
147
|
+
const out = [];
|
|
148
|
+
if (projects.length === 0) out.push(diagnostic("", "project-missing", `No Project concept in the bundle; software-project expects exactly one at the bundle root (${layout.root.project})`));
|
|
149
|
+
if (projects.length > 1) for (const project of projects) {
|
|
150
|
+
const others = projects.filter((other) => other !== project).map((other) => other.path).sort();
|
|
151
|
+
out.push(diagnostic(project.path, "project-multiple", `Project concept "${project.path}" is one of ${projects.length}; software-project expects exactly one (others: ${others.join(", ")})`));
|
|
152
|
+
}
|
|
153
|
+
for (const project of projects) if (project.path.includes("/")) out.push(diagnostic(project.path, "project-not-at-root", `Project concept "${project.path}" is below the bundle root; software-project expects it beside ${layout.root.index}`));
|
|
154
|
+
return out.sort(compare);
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* The `software-project` profile (spec 5; P-20 to P-26, P-42). Reachable
|
|
158
|
+
* publicly only as `Profiles.softwareProject` (P-38).
|
|
159
|
+
*
|
|
160
|
+
* @public
|
|
161
|
+
*/
|
|
162
|
+
const softwareProject = {
|
|
163
|
+
name: "software-project",
|
|
164
|
+
config,
|
|
165
|
+
layout,
|
|
166
|
+
check
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
export { softwareProject };
|
package/index.d.ts
CHANGED
|
@@ -1,21 +1,415 @@
|
|
|
1
|
-
|
|
1
|
+
import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
|
|
2
|
+
import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
|
|
3
|
+
import { Actor, Diagnostic, DiagnosticRange, LoadedBundle, OkfitConfig } from "@okfit/core";
|
|
4
|
+
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
5
|
+
//#region src/BodyProvenance.d.ts
|
|
2
6
|
/**
|
|
3
|
-
*
|
|
7
|
+
* The body is at HEAD and last changed in the commit described here (P-9).
|
|
8
|
+
* `at` is that commit's author date (P-4); `committedAt` and the author
|
|
9
|
+
* identity are carried for callers that want a history-based policy later (P-40).
|
|
10
|
+
* `creating` is true exactly when the winning entry is also the oldest entry
|
|
11
|
+
* of the path's history — the commit that created the path (S-9).
|
|
4
12
|
*
|
|
5
|
-
* @
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export declare const BodyCommitted: Schema.TaggedStruct<"committed", {
|
|
16
|
+
readonly at: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
|
|
17
|
+
readonly sha: Schema.String;
|
|
18
|
+
readonly committedAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
|
|
19
|
+
readonly authorName: Schema.String;
|
|
20
|
+
readonly authorEmail: Schema.String;
|
|
21
|
+
readonly creating: Schema.Boolean;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* The body has no usable commit (P-9): `untracked` means HEAD does not contain
|
|
25
|
+
* the path, `dirty` means the on-disk body differs from HEAD's blob, `unborn`
|
|
26
|
+
* means HEAD has no commits (P-11).
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
export declare const BodyUncommitted: Schema.TaggedStruct<"uncommitted", {
|
|
31
|
+
readonly reason: Schema.Literals<readonly ["untracked", "dirty", "unborn"]>;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* What {@link Derivation.generatedAt} reports. A schema so the value also
|
|
35
|
+
* serialises for MCP later without adaptation. Derivation never substitutes
|
|
36
|
+
* `now`; the uncommitted policy is the caller's (P-10).
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
export declare const BodyProvenance: Schema.Union<readonly [Schema.TaggedStruct<"committed", {
|
|
41
|
+
readonly at: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
|
|
42
|
+
readonly sha: Schema.String;
|
|
43
|
+
readonly committedAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
|
|
44
|
+
readonly authorName: Schema.String;
|
|
45
|
+
readonly authorEmail: Schema.String;
|
|
46
|
+
readonly creating: Schema.Boolean;
|
|
47
|
+
}>, Schema.TaggedStruct<"uncommitted", {
|
|
48
|
+
readonly reason: Schema.Literals<readonly ["untracked", "dirty", "unborn"]>;
|
|
49
|
+
}>]>;
|
|
50
|
+
/**
|
|
51
|
+
* The type of {@link (BodyProvenance:variable)}.
|
|
52
|
+
*
|
|
53
|
+
* @public
|
|
54
|
+
*/
|
|
55
|
+
export type BodyProvenance = typeof BodyProvenance.Type;
|
|
56
|
+
/**
|
|
57
|
+
* Why a body is uncommitted (P-9).
|
|
58
|
+
*
|
|
59
|
+
* @public
|
|
60
|
+
*/
|
|
61
|
+
type UncommittedReason = typeof BodyUncommitted.fields.reason.Type;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/GitHistory.d.ts
|
|
64
|
+
declare const PathHistoryEntry_base: Schema.Class<PathHistoryEntry, Schema.Struct<{
|
|
65
|
+
readonly sha: Schema.String;
|
|
66
|
+
readonly authoredAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
|
|
67
|
+
readonly committedAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
|
|
68
|
+
readonly authorName: Schema.String;
|
|
69
|
+
readonly authorEmail: Schema.String;
|
|
70
|
+
readonly path: Schema.String;
|
|
71
|
+
}>, {}>;
|
|
72
|
+
/**
|
|
73
|
+
* One record of the path log, newest first (P-3, P-5): the commit, both dates decoded through core's
|
|
74
|
+
* `Timestamp` (`%aI` / `%cI`, strict ISO with an offset; the `Type` is `DateTime.Utc`, P-4), the author
|
|
75
|
+
* identity, and the repository-relative posix path the blob had at that commit (the `--name-only` line).
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
export declare class PathHistoryEntry extends PathHistoryEntry_base {}
|
|
79
|
+
declare const GitHistoryError_base: Schema.Class<GitHistoryError, Schema.TaggedStruct<"GitHistoryError", {
|
|
80
|
+
/** The argv without the leading `git`. */
|
|
81
|
+
readonly args: Schema.$Array<Schema.String>;
|
|
82
|
+
/** The working directory the command ran in. */
|
|
83
|
+
readonly cwd: Schema.String;
|
|
84
|
+
/** git's exit code; absent when git never produced one. */
|
|
85
|
+
readonly exitCode: Schema.optionalKey<Schema.Number>;
|
|
86
|
+
/** git's stderr, captured under `LC_ALL=C`. */
|
|
87
|
+
readonly stderr: Schema.String;
|
|
88
|
+
/** Spawn failure text, `timed out after 30s`, or `Git.log`'s own absorbed-failure detail. */
|
|
89
|
+
readonly detail: Schema.optionalKey<Schema.String>;
|
|
90
|
+
}>, import("effect/Cause").YieldableError>;
|
|
91
|
+
/**
|
|
92
|
+
* Profiles' own spawn failure, shaped like `@effected/git`'s `GitCommandError` (P-12). Raised by
|
|
93
|
+
* `GitHistory.layer` for any `GitCommandError` `Git.log` produces, including the one `Git.log`
|
|
94
|
+
* itself synthesizes when its own spawn timeout expires.
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export declare class GitHistoryError extends GitHistoryError_base {
|
|
98
|
+
get message(): string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Options for `pathLog`; `limit` becomes `Git.log`'s `--max-count=<n>` (P-3, P-8).
|
|
102
|
+
* @public
|
|
103
|
+
*/
|
|
104
|
+
interface PathLogOptions {
|
|
105
|
+
readonly limit?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The service shape. `cwd` is any directory inside the repository; `path` is relative to `cwd` (git
|
|
109
|
+
* pathspec semantics). The result is newest first, and empty when no commit contains the path or HEAD is
|
|
110
|
+
* unborn (P-11).
|
|
111
|
+
* @public
|
|
112
|
+
*/
|
|
113
|
+
interface GitHistoryShape {
|
|
114
|
+
readonly pathLog: (cwd: string, path: string, options?: PathLogOptions) => Effect.Effect<ReadonlyArray<PathHistoryEntry>, GitHistoryError | NotARepositoryError>;
|
|
115
|
+
}
|
|
116
|
+
declare const GitHistory_base: Context.ServiceClass<GitHistory, "@okfit/profiles/GitHistory", GitHistoryShape>;
|
|
117
|
+
/**
|
|
118
|
+
* A path-scoped, date-bearing log: `Git.log` reads the whole repository history, and this adapter scopes
|
|
119
|
+
* it to one path (`--follow`, `--diff-merges=first-parent`) and decodes it into `PathHistoryEntry`.
|
|
120
|
+
* Delegates to `Git.log` as of `@effected/git` 0.12.0 (P-1, spec deviation retired).
|
|
121
|
+
* @public
|
|
122
|
+
*/
|
|
123
|
+
export declare class GitHistory extends GitHistory_base {
|
|
124
|
+
/**
|
|
125
|
+
* Live layer (P-1, P-5, P-11): one `Git.log` call per `pathLog`, resolved once at construction like
|
|
126
|
+
* `Git.layer` itself, so every member's `R` is `never`. `Git.log` already degrades an unborn `HEAD` and
|
|
127
|
+
* an unmatched pathspec to the empty array and classifies `not a git repository` as `NotARepositoryError`
|
|
128
|
+
* (both pass through unchanged); every other failure becomes `GitHistoryError`, including the one
|
|
129
|
+
* `Git.log` itself produces when its own 30 s spawn timeout expires (`@effected/git`'s `GIT_TIMEOUT`
|
|
130
|
+
* ceiling, not this adapter's) — `Git.log` never lets a bare `Cause.TimeoutError` escape. The CLI
|
|
131
|
+
* provides `ChildProcessSpawner` through `NodeServices.layer` (P-30).
|
|
132
|
+
*/
|
|
133
|
+
static readonly layer: Layer.Layer<GitHistory, never, ChildProcessSpawner.ChildProcessSpawner>;
|
|
134
|
+
/**
|
|
135
|
+
* Scripted double (P-32): responses keyed by the `path` argument exactly as passed to `pathLog`. A call
|
|
136
|
+
* whose path has no entry DIES with `GitHistory.makeTest: pathLog(<path>) was called but not scripted`,
|
|
137
|
+
* mirroring `Git.makeTest`; `limit` is honoured by slicing. Models none of the live semantics.
|
|
138
|
+
*/
|
|
139
|
+
static readonly makeTest: (script: Readonly<Record<string, ReadonlyArray<PathHistoryEntry>>>) => GitHistoryShape;
|
|
140
|
+
/**
|
|
141
|
+
* `Layer.succeed(GitHistory, GitHistory.makeTest(script))`. Mints a fresh layer per call: bind it to a
|
|
142
|
+
* `const`, since layers memoize by reference.
|
|
143
|
+
*/
|
|
144
|
+
static readonly layerTest: (script: Readonly<Record<string, ReadonlyArray<PathHistoryEntry>>>) => Layer.Layer<GitHistory>;
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/Derivation.d.ts
|
|
148
|
+
/**
|
|
149
|
+
* Who is writing now. Explicit, never read from the environment (P-16).
|
|
150
|
+
*
|
|
151
|
+
* @public
|
|
152
|
+
*/
|
|
153
|
+
type Writer = "agent" | "human";
|
|
154
|
+
declare const HumanActorUnresolvedError_base: Schema.Class<HumanActorUnresolvedError, Schema.TaggedStruct<"HumanActorUnresolvedError", {
|
|
155
|
+
readonly userName: Schema.optionalKey<Schema.String>;
|
|
156
|
+
readonly userEmail: Schema.optionalKey<Schema.String>;
|
|
157
|
+
}>, import("effect/Cause").YieldableError>;
|
|
158
|
+
/**
|
|
159
|
+
* git has no identity to derive a `human:` actor from (P-13 step 4).
|
|
160
|
+
* `userName` and `userEmail` are the trimmed `configGet` answers when git had
|
|
161
|
+
* them and absent keys otherwise (decision 53: not `name`/`email`, since every
|
|
162
|
+
* Effect error class carries the class name on its own prototype and a field
|
|
163
|
+
* literally named `name` would shadow `error.name` when present); test for
|
|
164
|
+
* git's answer with `Object.hasOwn(error, "userName")`.
|
|
165
|
+
*
|
|
166
|
+
* @public
|
|
6
167
|
*/
|
|
168
|
+
export declare class HumanActorUnresolvedError extends HumanActorUnresolvedError_base {
|
|
169
|
+
get message(): string;
|
|
170
|
+
}
|
|
171
|
+
declare const AgentActorUnconfiguredError_base: Schema.Class<AgentActorUnconfiguredError, Schema.TaggedStruct<"AgentActorUnconfiguredError", {}>, import("effect/Cause").YieldableError>;
|
|
7
172
|
/**
|
|
8
|
-
*
|
|
173
|
+
* The writer is an agent but `actors.agent` is not configured (P-17); the
|
|
174
|
+
* profile never sets it.
|
|
9
175
|
*
|
|
10
176
|
* @public
|
|
11
177
|
*/
|
|
12
|
-
declare
|
|
178
|
+
export declare class AgentActorUnconfiguredError extends AgentActorUnconfiguredError_base {
|
|
179
|
+
get message(): string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Options for {@link Derivation.generatedAt} (P-39).
|
|
183
|
+
*
|
|
184
|
+
* @public
|
|
185
|
+
*/
|
|
186
|
+
interface GeneratedAtOptions {
|
|
187
|
+
/** ABSOLUTE path to the concept file on disk. */
|
|
188
|
+
readonly file: string;
|
|
189
|
+
/** Reserved (P-48): accepted and ignored in phase 1; no key affects the walk. */
|
|
190
|
+
readonly config?: OkfitConfig;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Everything {@link Derivation.generatedAt} can fail with: profiles' own
|
|
194
|
+
* `GitHistoryError`, the `@effected/git` peer's errors (P-12), and the
|
|
195
|
+
* `PlatformError` of `realPath`/`readFileString` on `file`.
|
|
196
|
+
*
|
|
197
|
+
* @public
|
|
198
|
+
*/
|
|
199
|
+
type GeneratedAtError = GitHistoryError | GitCommandError | NotARepositoryError | UnknownRefError | PlatformError.PlatformError;
|
|
200
|
+
/**
|
|
201
|
+
* Options for {@link Derivation.generatedBy} (P-16, P-45).
|
|
202
|
+
*
|
|
203
|
+
* @public
|
|
204
|
+
*/
|
|
205
|
+
interface GeneratedByOptions {
|
|
206
|
+
readonly writer: Writer;
|
|
207
|
+
/** Any directory inside the repository; every `Git` member takes a cwd and profiles never reads `process.cwd()`. */
|
|
208
|
+
readonly cwd: string;
|
|
209
|
+
/** Merged or raw; only `actors` is read. */
|
|
210
|
+
readonly config: OkfitConfig;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Everything {@link Derivation.generatedBy} can fail with.
|
|
214
|
+
*
|
|
215
|
+
* @public
|
|
216
|
+
*/
|
|
217
|
+
type GeneratedByError = AgentActorUnconfiguredError | HumanActorUnresolvedError | GitCommandError | NotARepositoryError | UnknownRefError;
|
|
218
|
+
/**
|
|
219
|
+
* Trimmed `user.name` / `user.email` as `Git.configGet` answers them; a key is absent when git has no value.
|
|
220
|
+
*
|
|
221
|
+
* @public
|
|
222
|
+
*/
|
|
223
|
+
interface GitIdentity {
|
|
224
|
+
readonly name?: string;
|
|
225
|
+
readonly email?: string;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Spec 5.4 derivation rules for the `software-project` profile, package-global
|
|
229
|
+
* (P-38). Pure members take every input as an argument; effectful members
|
|
230
|
+
* require `Git`, `GitHistory` and, for the worktree read, `FileSystem` and
|
|
231
|
+
* `Path` (P-30). Never writes, never reads `generated.by` (P-40).
|
|
232
|
+
*
|
|
233
|
+
* @public
|
|
234
|
+
*/
|
|
235
|
+
export declare class Derivation {
|
|
236
|
+
private constructor();
|
|
237
|
+
/**
|
|
238
|
+
* `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
|
|
239
|
+
* text removed (P-6). Pure. Applied to both sides of every comparison so `core.autocrlf` worktrees compare clean.
|
|
240
|
+
*/
|
|
241
|
+
static readonly body: (text: string) => string;
|
|
242
|
+
/**
|
|
243
|
+
* Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
|
|
244
|
+
* 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
|
|
245
|
+
* submodules and worktrees work because the question is asked from the file's directory (P-39).
|
|
246
|
+
* 2. `realPath` both (rev-parse is symlink-resolved), then the repo-relative posix path via `Path` (P-39).
|
|
247
|
+
* 3. `worktree = body(readFileString(file))`.
|
|
248
|
+
* 4. `Git.show(realRoot, "HEAD", rel)`: `UnknownRefError` is `uncommitted { unborn }` (P-11), `Option.none`
|
|
249
|
+
* is `uncommitted { untracked }` (P-47), a body differing from `worktree` is `uncommitted { dirty }` (P-9).
|
|
250
|
+
* The comparison is against HEAD's blob, never the newest log entry (P-46).
|
|
251
|
+
* 5. `entries = GitHistory.pathLog(realRoot, rel)` newest first (P-5, P-46); `[]` is `uncommitted { unborn }`.
|
|
252
|
+
* 6. One `Git.show` per older blob (P-8), newest first: the first entry whose body differs from the next
|
|
253
|
+
* older entry's body wins, else the oldest entry (the creating commit). "Previous" is the previous entry
|
|
254
|
+
* of the simplified `--follow` history, not the true parent (P-7). The newest entry's body is never
|
|
255
|
+
* re-fetched: `head.blob`, already confirmed equal to `worktree` by step 4, is reused (decision 56).
|
|
256
|
+
* `Option.none` for an older entry counts as "differs", never a defect (decision 56).
|
|
257
|
+
* `config` is reserved and ignored (P-48). Never writes, never reads `by` (P-40).
|
|
258
|
+
*/
|
|
259
|
+
static readonly generatedAt: (options: GeneratedAtOptions) => Effect.Effect<BodyProvenance, GeneratedAtError, Git | GitHistory | FileSystem.FileSystem | Path.Path>;
|
|
260
|
+
/**
|
|
261
|
+
* P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
|
|
262
|
+
* and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
|
|
263
|
+
* candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
|
|
264
|
+
* The result is branded through core's `Actor` codec and always passes it.
|
|
265
|
+
*/
|
|
266
|
+
static readonly humanActorId: (identity: GitIdentity, humans: ReadonlyArray<Actor>) => Option.Option<Actor>;
|
|
267
|
+
/**
|
|
268
|
+
* Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
|
|
269
|
+
* (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
|
|
270
|
+
* scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
|
|
271
|
+
* Answers only "who is writing now" (P-40).
|
|
272
|
+
*/
|
|
273
|
+
static readonly generatedBy: (options: GeneratedByOptions) => Effect.Effect<Actor, GeneratedByError, Git>;
|
|
274
|
+
/**
|
|
275
|
+
* P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
|
|
276
|
+
* 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
|
|
277
|
+
*/
|
|
278
|
+
static readonly staleAfter: (from: DateTime.Utc, config: OkfitConfig) => DateTime.Utc;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/Profile.d.ts
|
|
282
|
+
/**
|
|
283
|
+
* Names of the profiles this package ships (P-38).
|
|
284
|
+
*
|
|
285
|
+
* @public
|
|
286
|
+
*/
|
|
287
|
+
export declare const PROFILE_NAMES: readonly ["software-project"];
|
|
13
288
|
/**
|
|
14
289
|
* A profile name shipped by this package.
|
|
15
290
|
*
|
|
16
291
|
* @public
|
|
17
292
|
*/
|
|
18
293
|
type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
294
|
+
/**
|
|
295
|
+
* One concept directory of a profile's bundle layout (P-26). Each directory
|
|
296
|
+
* carries its own `index.md`; `type` is the `types.<Name>` key held there.
|
|
297
|
+
*
|
|
298
|
+
* @public
|
|
299
|
+
*/
|
|
300
|
+
interface LayoutDirectory {
|
|
301
|
+
readonly directory: string;
|
|
302
|
+
readonly type: string;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Scaffolding data for `okfit init` (spec 5.2); never part of `OkfitConfig`
|
|
306
|
+
* (P-26). What the CLI writes into these files is the CLI plan's concern.
|
|
307
|
+
*
|
|
308
|
+
* @public
|
|
309
|
+
*/
|
|
310
|
+
interface Layout {
|
|
311
|
+
readonly root: {
|
|
312
|
+
readonly index: "index.md";
|
|
313
|
+
readonly log: "log.md";
|
|
314
|
+
readonly project: "project.md";
|
|
315
|
+
};
|
|
316
|
+
readonly directories: ReadonlyArray<LayoutDirectory>;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Profile-owned diagnostic codes (P-21). Core's `LintCode` is untouched; all
|
|
320
|
+
* three are severity `error` and are not configurable.
|
|
321
|
+
*
|
|
322
|
+
* @public
|
|
323
|
+
*/
|
|
324
|
+
export declare const ProfileDiagnosticCode: Schema.Literals<readonly ["project-missing", "project-multiple", "project-not-at-root"]>;
|
|
325
|
+
/**
|
|
326
|
+
* The type of `ProfileDiagnosticCode`.
|
|
327
|
+
*
|
|
328
|
+
* @public
|
|
329
|
+
*/
|
|
330
|
+
export type ProfileDiagnosticCode = typeof ProfileDiagnosticCode.Type;
|
|
331
|
+
/**
|
|
332
|
+
* Same fields as core's `Diagnostic` with a profile code; a `Schema.Struct`
|
|
333
|
+
* per P-21/P-51. `file` is the bundle-relative posix path, `""` for the
|
|
334
|
+
* bundle-level `project-missing`. `range` is never set in phase 1.
|
|
335
|
+
*
|
|
336
|
+
* @public
|
|
337
|
+
*/
|
|
338
|
+
export declare const ProfileDiagnostic: Schema.Struct<{
|
|
339
|
+
readonly file: Schema.String;
|
|
340
|
+
readonly range: Schema.optionalKey<typeof DiagnosticRange>;
|
|
341
|
+
readonly code: Schema.Literals<readonly ["project-missing", "project-multiple", "project-not-at-root"]>;
|
|
342
|
+
readonly severity: Schema.Literals<readonly ["error", "warning", "info"]>;
|
|
343
|
+
readonly message: Schema.String;
|
|
344
|
+
}>;
|
|
345
|
+
/**
|
|
346
|
+
* The type of `ProfileDiagnostic`.
|
|
347
|
+
*
|
|
348
|
+
* @public
|
|
349
|
+
*/
|
|
350
|
+
export type ProfileDiagnostic = typeof ProfileDiagnostic.Type;
|
|
351
|
+
/**
|
|
352
|
+
* A named profile (P-38): a partial `OkfitConfig` the CLI merges as
|
|
353
|
+
* `DEFAULTS < profile < file`, a scaffolding `Layout`, and a pure `check`
|
|
354
|
+
* over a loaded bundle run after `Validate.all`.
|
|
355
|
+
*
|
|
356
|
+
* @public
|
|
357
|
+
*/
|
|
358
|
+
interface Profile {
|
|
359
|
+
readonly name: ProfileName;
|
|
360
|
+
readonly config: OkfitConfig;
|
|
361
|
+
readonly layout: Layout;
|
|
362
|
+
readonly check: (bundle: LoadedBundle) => ReadonlyArray<ProfileDiagnostic>;
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/Profiles.d.ts
|
|
366
|
+
/**
|
|
367
|
+
* The profile registry (P-38). No third-party registration in phase 1; the
|
|
368
|
+
* `DEFAULTS < profile < file` merge is the CLI's (D-28).
|
|
369
|
+
*
|
|
370
|
+
* @public
|
|
371
|
+
*/
|
|
372
|
+
export declare class Profiles {
|
|
373
|
+
private constructor();
|
|
374
|
+
/** The `software-project` profile (spec 5). */
|
|
375
|
+
static readonly softwareProject: Profile;
|
|
376
|
+
/**
|
|
377
|
+
* `Option.none` for `"none"`, unknown names and anything not in
|
|
378
|
+
* `PROFILE_NAMES`; the CLI decides what that means (P-38). Exact match only.
|
|
379
|
+
*/
|
|
380
|
+
static readonly get: (name: string) => Option.Option<Profile>;
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/Provenance.d.ts
|
|
384
|
+
/**
|
|
385
|
+
* A profiles-owned lint over committed provenance (S-8). NOT a `Profile`
|
|
386
|
+
* member and NOT a `ProfileDiagnostic` -- it returns core `Diagnostic`s with
|
|
387
|
+
* code `"generated-at-drift"`, a real `LintCode` member, so `severityFor`,
|
|
388
|
+
* the `[lint]` table, the renderers, the hook, and the MCP tool treat it
|
|
389
|
+
* like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
|
|
390
|
+
* are unchanged.
|
|
391
|
+
*
|
|
392
|
+
* Runs `Derivation.generatedAt` per concept, skips uncommitted bodies and
|
|
393
|
+
* concepts without a `generated` block, and returns `[]` the moment a
|
|
394
|
+
* `NotARepositoryError` surfaces: a bundle outside git has nothing to
|
|
395
|
+
* derive for any concept, so the first occurrence short-circuits the whole
|
|
396
|
+
* walk, not just the concept that triggered it (S-8). Emits no `range`
|
|
397
|
+
* (S-12, Judge note 7): `Diagnostic.range` is `Schema.optionalKey` and is
|
|
398
|
+
* simply omitted.
|
|
399
|
+
*
|
|
400
|
+
* `Provenance.lint` is total over severity (S-28, amending Judge note 3):
|
|
401
|
+
* when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
|
|
402
|
+
* `"off"`, it returns `[]` before any per-concept work and before any git
|
|
403
|
+
* call, so it is safe to call at any severity and never needs to cast
|
|
404
|
+
* `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
|
|
405
|
+
* calling at all; the two agree by construction (both skip the walk).
|
|
406
|
+
*
|
|
407
|
+
* @public
|
|
408
|
+
*/
|
|
409
|
+
export declare class Provenance {
|
|
410
|
+
private constructor();
|
|
411
|
+
static readonly lint: (bundle: LoadedBundle, config: OkfitConfig) => Effect.Effect<ReadonlyArray<Diagnostic>, GitHistoryError | GitCommandError | UnknownRefError | PlatformError.PlatformError, Git | GitHistory | FileSystem.FileSystem | Path.Path>;
|
|
412
|
+
}
|
|
19
413
|
//#endregion
|
|
20
|
-
export {
|
|
414
|
+
export type { GeneratedAtError, GeneratedAtOptions, GeneratedByError, GeneratedByOptions, GitHistoryShape, GitIdentity, Layout, LayoutDirectory, PathLogOptions, Profile, ProfileName, UncommittedReason, Writer };
|
|
21
415
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Names of the profiles this package ships.
|
|
9
|
-
*
|
|
10
|
-
* @public
|
|
11
|
-
*/
|
|
12
|
-
const PROFILE_NAMES = ["software-project"];
|
|
1
|
+
import { BodyCommitted, BodyProvenance, BodyUncommitted } from "./BodyProvenance.js";
|
|
2
|
+
import { GitHistory, GitHistoryError, PathHistoryEntry } from "./GitHistory.js";
|
|
3
|
+
import { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError } from "./Derivation.js";
|
|
4
|
+
import { PROFILE_NAMES, ProfileDiagnostic, ProfileDiagnosticCode } from "./Profile.js";
|
|
5
|
+
import { Profiles } from "./Profiles.js";
|
|
6
|
+
import { Provenance } from "./Provenance.js";
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
export { PROFILE_NAMES };
|
|
8
|
+
export { AgentActorUnconfiguredError, BodyCommitted, BodyProvenance, BodyUncommitted, Derivation, GitHistory, GitHistoryError, HumanActorUnresolvedError, PROFILE_NAMES, PathHistoryEntry, ProfileDiagnostic, ProfileDiagnosticCode, Profiles, Provenance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okfit/profiles",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Named OKF configuration profiles for okfit, starting with the software-project profile.",
|
|
6
6
|
"keywords": [
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
},
|
|
35
35
|
"./package.json": "./package.json"
|
|
36
36
|
},
|
|
37
|
-
"
|
|
38
|
-
"@
|
|
39
|
-
"
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@effected/git": "^0.12.0",
|
|
39
|
+
"@effected/markdown": "^0.9.0",
|
|
40
|
+
"@okfit/core": "^0.2.0",
|
|
41
|
+
"effect": "4.0.0-rc.112"
|
|
40
42
|
},
|
|
41
43
|
"engines": {
|
|
42
44
|
"node": ">=24.11.0"
|