@okfit/cli 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/README.md +332 -3
- package/bin/okfit.js +47 -4
- package/commands/context.js +78 -0
- package/commands/init.js +177 -0
- package/commands/root.js +26 -3
- package/commands/sync.js +111 -0
- package/commands/validate.js +98 -0
- package/commands/verify.js +98 -0
- package/config/anchor.js +57 -0
- package/config/layer.js +129 -0
- package/config/resolve.js +67 -0
- package/context/run.js +35 -0
- package/errors.js +173 -0
- package/index.d.ts +891 -6
- package/index.js +15 -10
- package/init/scaffold.js +174 -0
- package/internal/exit.js +14 -0
- package/internal/tty.js +13 -0
- package/package.js +5 -0
- package/package.json +17 -5
- package/render/context.js +108 -0
- package/render/exit.js +53 -0
- package/render/human.js +68 -0
- package/render/json.js +126 -0
- package/render/sort.js +46 -0
- package/render/sync.js +101 -0
- package/render/verify.js +72 -0
- package/sync/generated.js +111 -0
- package/sync/index.js +73 -0
- package/sync/log.js +127 -0
- package/sync/run.js +65 -0
- package/sync/write.js +48 -0
- package/validate/run.js +63 -0
- package/verify/locate.js +249 -0
- package/verify/run.js +106 -0
- package/verify/splice.js +75 -0
- package/version.js +14 -0
package/render/sync.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { SkipReason } from "../sync/run.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/render/sync.ts
|
|
5
|
+
/** @public */
|
|
6
|
+
const SyncModeEnvelope = Schema.Struct({
|
|
7
|
+
selected: Schema.Boolean,
|
|
8
|
+
written: Schema.Array(Schema.String),
|
|
9
|
+
unchanged: Schema.Array(Schema.String),
|
|
10
|
+
skipped: Schema.Array(Schema.Struct({
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
reason: SkipReason
|
|
13
|
+
}))
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Contract §14 note 5: `schema: 1` is INFERRED, not in the design's own
|
|
17
|
+
* §2 JSON example — every other envelope in this codebase
|
|
18
|
+
* (`JsonEnvelope`, `VerifyEnvelope`, `ContextEnvelope`) opens with it, so
|
|
19
|
+
* this one does too. `exit_code` is the literal `0`: `sync` has no
|
|
20
|
+
* content tier (unlike `validate`'s `0 | 1 | 2`) and this envelope only
|
|
21
|
+
* exists on the success path — a failure produces `JsonErrorEnvelope`
|
|
22
|
+
* (K-22) instead, unchanged.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
const SyncEnvelope = Schema.Struct({
|
|
27
|
+
schema: Schema.Literal(1),
|
|
28
|
+
okfit_version: Schema.String,
|
|
29
|
+
root: Schema.String,
|
|
30
|
+
dry_run: Schema.Boolean,
|
|
31
|
+
exit_code: Schema.Literal(0),
|
|
32
|
+
generated: SyncModeEnvelope,
|
|
33
|
+
index: SyncModeEnvelope,
|
|
34
|
+
log: SyncModeEnvelope
|
|
35
|
+
});
|
|
36
|
+
const toModeEnvelope = (mode) => ({
|
|
37
|
+
selected: mode.selected,
|
|
38
|
+
written: [...mode.written],
|
|
39
|
+
unchanged: [...mode.unchanged],
|
|
40
|
+
skipped: mode.skipped.map((entry) => ({
|
|
41
|
+
id: entry.id,
|
|
42
|
+
reason: entry.reason
|
|
43
|
+
}))
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* `root` alone goes through `displayRoot` at the call site
|
|
47
|
+
* (`commands/sync.ts`) — `generated`/`index`/`log`'s own lists are
|
|
48
|
+
* already bundle-relative and need no cwd-relativisation (contract §9.1).
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
const syncEnvelope = (input) => ({
|
|
53
|
+
schema: 1,
|
|
54
|
+
okfit_version: input.okfitVersion,
|
|
55
|
+
root: input.root,
|
|
56
|
+
dry_run: input.dryRun,
|
|
57
|
+
exit_code: 0,
|
|
58
|
+
generated: toModeEnvelope(input.result.generated),
|
|
59
|
+
index: toModeEnvelope(input.result.index),
|
|
60
|
+
log: toModeEnvelope(input.result.log)
|
|
61
|
+
});
|
|
62
|
+
/** Contract §9.2's fixed reason-sentence table, closed over `SkipReason`. */
|
|
63
|
+
const REASON_SENTENCE = {
|
|
64
|
+
untracked: "not tracked by git",
|
|
65
|
+
dirty: "has uncommitted changes",
|
|
66
|
+
unborn: "the repository has no commits yet",
|
|
67
|
+
"generated-missing": "has no generated block",
|
|
68
|
+
"generated-unsupported": "generated.at is a shape sync cannot edit; edit it by hand",
|
|
69
|
+
"log-unparseable": "log.md could not be parsed; see okfit validate"
|
|
70
|
+
};
|
|
71
|
+
const humanMode = (name, mode) => {
|
|
72
|
+
if (!mode.selected) return [`${name}: not selected`];
|
|
73
|
+
const lines = [`${name}:`];
|
|
74
|
+
for (const id of mode.written) lines.push(` wrote ${id}`);
|
|
75
|
+
for (const id of mode.unchanged) lines.push(` unchanged ${id}`);
|
|
76
|
+
for (const entry of mode.skipped) lines.push(` skipped ${entry.id}: ${REASON_SENTENCE[entry.reason]}`);
|
|
77
|
+
return lines;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Contract §9.2 (design §2): per mode, three lists, then a one-line
|
|
81
|
+
* summary. A mode `--only` excluded from renders as `not selected` rather
|
|
82
|
+
* than three empty lists — an implementer choice within the fixed JSON
|
|
83
|
+
* shape (§14 makes no ruling on the human half; this is cosmetic).
|
|
84
|
+
*
|
|
85
|
+
* @public
|
|
86
|
+
*/
|
|
87
|
+
const humanSync = (result) => {
|
|
88
|
+
const lines = [
|
|
89
|
+
...humanMode("generated", result.generated),
|
|
90
|
+
...humanMode("index", result.index),
|
|
91
|
+
...humanMode("log", result.log)
|
|
92
|
+
];
|
|
93
|
+
const totalWritten = result.generated.written.length + result.index.written.length + result.log.written.length;
|
|
94
|
+
const totalUnchanged = result.generated.unchanged.length + result.index.unchanged.length + result.log.unchanged.length;
|
|
95
|
+
const totalSkipped = result.generated.skipped.length + result.index.skipped.length + result.log.skipped.length;
|
|
96
|
+
lines.push(result.dryRun ? `would write ${totalWritten}, unchanged ${totalUnchanged}, skipped ${totalSkipped} (dry run, nothing written)` : `wrote ${totalWritten}, unchanged ${totalUnchanged}, skipped ${totalSkipped}`);
|
|
97
|
+
return lines;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
export { SyncEnvelope, SyncModeEnvelope, humanSync, syncEnvelope };
|
package/render/verify.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/render/verify.ts
|
|
4
|
+
/**
|
|
5
|
+
* V-11's success envelope, schema 1, snake_case — the same convention as
|
|
6
|
+
* `render/json.ts`'s `JsonEnvelope`. Identical in shape for a dry run,
|
|
7
|
+
* which sets `dry_run: true` and still exits 0. There is no content tier,
|
|
8
|
+
* so `exit_code` is the literal `0`; a failure produces K-22's
|
|
9
|
+
* `JsonErrorEnvelope` instead, unchanged.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
const VerifyEnvelope = Schema.Struct({
|
|
14
|
+
schema: Schema.Literal(1),
|
|
15
|
+
okfit_version: Schema.String,
|
|
16
|
+
id: Schema.String,
|
|
17
|
+
path: Schema.String,
|
|
18
|
+
verified: Schema.Struct({
|
|
19
|
+
by: Schema.String,
|
|
20
|
+
at: Schema.String
|
|
21
|
+
}),
|
|
22
|
+
dry_run: Schema.Boolean,
|
|
23
|
+
exit_code: Schema.Literal(0)
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* `path` is already the display form — `displayRoot(cwd, bundle.root, path)`
|
|
27
|
+
* joined to the bundle-relative `concept.path` — because `LoadedConcept.path`
|
|
28
|
+
* alone would print `decisions/cli-exit-codes.md`, not
|
|
29
|
+
* `okf/decisions/cli-exit-codes.md` (contract §12 note 5).
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
const verifyEnvelope = (input) => ({
|
|
34
|
+
schema: 1,
|
|
35
|
+
okfit_version: input.okfitVersion,
|
|
36
|
+
id: input.id,
|
|
37
|
+
path: input.path,
|
|
38
|
+
verified: {
|
|
39
|
+
by: input.by,
|
|
40
|
+
at: input.at
|
|
41
|
+
},
|
|
42
|
+
dry_run: input.dryRun,
|
|
43
|
+
exit_code: 0
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Indent every line of `fragment` two spaces for display under a
|
|
47
|
+
* `would write:` header, dropping the single trailing empty line a
|
|
48
|
+
* newline-terminated fragment produces on split (I3).
|
|
49
|
+
*/
|
|
50
|
+
const indentFragment = (fragment) => {
|
|
51
|
+
const lines = fragment.split(/\r\n|\n/);
|
|
52
|
+
return (lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines).map((line) => ` ${line}`);
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* V-11's human output: one line per fact. One `already verified` line per
|
|
56
|
+
* prior entry by the same actor, in list order (V-2 says "entries",
|
|
57
|
+
* plural), then the success line. A prior entry by a DIFFERENT actor is
|
|
58
|
+
* not called out: it stays on disk untouched (V-1) and is simply not this
|
|
59
|
+
* line's subject. Under `--dry-run` (I3), a `would write:` header and the
|
|
60
|
+
* exact fragment a real run would splice in follow, so the preview
|
|
61
|
+
* exercises — and shows — the same edit the write path would make.
|
|
62
|
+
*
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
const humanVerify = (input) => [
|
|
66
|
+
...input.priorAt.map((at) => `already verified by ${input.by} at ${at}; appending`),
|
|
67
|
+
input.dryRun ? `would verify ${input.id} by ${input.by} at ${input.at} (dry run, nothing written)` : `verified ${input.id} by ${input.by} at ${input.at}`,
|
|
68
|
+
...input.dryRun ? ["would write:", ...indentFragment(input.fragment)] : []
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { VerifyEnvelope, humanVerify, verifyEnvelope };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { detectNewline, locateGenerated, stripBom } from "../verify/locate.js";
|
|
2
|
+
import { spliceGenerated } from "../verify/splice.js";
|
|
3
|
+
import { writeAtomic } from "./write.js";
|
|
4
|
+
import { DateTime, Effect, FileSystem, Path, Schema } from "effect";
|
|
5
|
+
import { Timestamp } from "@okfit/core";
|
|
6
|
+
import { MarkdownEdit } from "@effected/markdown";
|
|
7
|
+
|
|
8
|
+
//#region src/sync/generated.ts
|
|
9
|
+
/**
|
|
10
|
+
* The five `SkipReason` members contract §6.2's algorithm can actually
|
|
11
|
+
* produce — a strict subset of contract §5's six-member `SkipReason`
|
|
12
|
+
* (`sync/run.ts`, Task B3; the sixth, `"log-unparseable"`, is log mode's
|
|
13
|
+
* alone). Declared locally because `sync/run.ts` does not exist when this
|
|
14
|
+
* task lands (fixed order A1 -> A2 -> B1 -> B2 -> B3); every member here is
|
|
15
|
+
* spelled identically to its `SkipReason` counterpart, so Task B3 assigns
|
|
16
|
+
* a `GeneratedSkipReason` value into a `SkipReason`-typed field with no
|
|
17
|
+
* cast.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
const GeneratedSkipReason = Schema.Literals([
|
|
22
|
+
"untracked",
|
|
23
|
+
"dirty",
|
|
24
|
+
"unborn",
|
|
25
|
+
"generated-missing",
|
|
26
|
+
"generated-unsupported"
|
|
27
|
+
]);
|
|
28
|
+
const encodeAt = Schema.encodeSync(Timestamp);
|
|
29
|
+
/**
|
|
30
|
+
* Contract §6.2's twelve-step algorithm, run once per `bundle.concepts`
|
|
31
|
+
* entry (S-22: `index.md`/`log.md` are reserved files, never concepts,
|
|
32
|
+
* never seen here). `provenance` is the single `Derivation.generatedAt`
|
|
33
|
+
* walk `runSync` (Task B3) computes once and shares with log mode; this
|
|
34
|
+
* function never calls git itself and never substitutes `now` for an
|
|
35
|
+
* uncommitted body (design §3 rule 2). Every write is atomic (temp file
|
|
36
|
+
* plus rename in the same directory), matching `verify`'s own discipline
|
|
37
|
+
* (`verify/run.ts`'s `runVerify`).
|
|
38
|
+
*
|
|
39
|
+
* `locateGenerated`'s `YamlParseError` is folded into a defect here
|
|
40
|
+
* (`Effect.orDie`), not widened into this function's typed error channel:
|
|
41
|
+
* the frontmatter it re-parses already decoded once, successfully, for
|
|
42
|
+
* this concept to be a member of `bundle.concepts` at all -- the same
|
|
43
|
+
* reasoning `locate.ts`'s own `locate` documents for its identical
|
|
44
|
+
* failure mode. `runSync`'s stated error union (`BundleLoadError |
|
|
45
|
+
* GeneratedAtError`, contract §5) already includes `PlatformError.
|
|
46
|
+
* PlatformError` inside `GeneratedAtError` (contract §14 note 2), so this
|
|
47
|
+
* function's own `PlatformError.PlatformError` channel composes with no
|
|
48
|
+
* widening on either side.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
const syncGenerated = Effect.fn("okfit/sync/syncGenerated")(function* (bundle, provenance, dryRun) {
|
|
53
|
+
const fs = yield* FileSystem.FileSystem;
|
|
54
|
+
const path = yield* Path.Path;
|
|
55
|
+
const written = [];
|
|
56
|
+
const unchanged = [];
|
|
57
|
+
const skipped = [];
|
|
58
|
+
for (const [id, concept] of bundle.concepts) {
|
|
59
|
+
const derived = provenance.get(id);
|
|
60
|
+
if (derived === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(`syncGenerated: no provenance computed for concept ${id}`));
|
|
61
|
+
if (derived._tag === "uncommitted") {
|
|
62
|
+
skipped.push({
|
|
63
|
+
id,
|
|
64
|
+
reason: derived.reason
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const generated = concept.frontmatter.generated;
|
|
69
|
+
if (generated === void 0) {
|
|
70
|
+
skipped.push({
|
|
71
|
+
id,
|
|
72
|
+
reason: "generated-missing"
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const recorded = generated.at;
|
|
77
|
+
if (recorded !== void 0 && DateTime.Equivalence(recorded, derived.at)) {
|
|
78
|
+
unchanged.push(id);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const absolutePath = path.join(bundle.root, concept.path);
|
|
82
|
+
const source = yield* fs.readFileString(absolutePath);
|
|
83
|
+
const { text, bom } = stripBom(source);
|
|
84
|
+
const newline = detectNewline(text);
|
|
85
|
+
const located = yield* locateGenerated(text).pipe(Effect.orDie);
|
|
86
|
+
if (located._tag === "unsupported") {
|
|
87
|
+
skipped.push({
|
|
88
|
+
id,
|
|
89
|
+
reason: "generated-unsupported"
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const encodedAt = encodeAt(derived.at);
|
|
94
|
+
const edit = spliceGenerated(located, encodedAt, newline);
|
|
95
|
+
const finalText = bom + MarkdownEdit.applyAll(text, [edit]);
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
written.push(id);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
yield* writeAtomic(absolutePath, finalText);
|
|
101
|
+
written.push(id);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
written,
|
|
105
|
+
unchanged,
|
|
106
|
+
skipped
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
export { syncGenerated };
|
package/sync/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { writeAtomic } from "./write.js";
|
|
2
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
3
|
+
import { Derive, OKF_SPEC_VERSION } from "@okfit/core";
|
|
4
|
+
|
|
5
|
+
//#region src/sync/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* Immediate child directories of `dir`, unioned from `bundle.directories`
|
|
8
|
+
* and `bundle.indexes.keys()` -- the same union `Derive.synthesizeIndex`
|
|
9
|
+
* walks (S-29, amending S-20's wording): a directory holding only an
|
|
10
|
+
* `index.md` and no concepts is never a member of `bundle.directories`
|
|
11
|
+
* (`Bundle.load` records a directory there only when it holds a concept),
|
|
12
|
+
* but it is still a key of `bundle.indexes`, so omitting that map would
|
|
13
|
+
* silently drop it from the root's `# Subdirectories` section while every
|
|
14
|
+
* other directory's own `synthesizeIndex` call still lists it as a child.
|
|
15
|
+
*/
|
|
16
|
+
const childDirectoriesOf = (bundle, dir) => {
|
|
17
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
18
|
+
const children = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const candidate of [...bundle.directories, ...bundle.indexes.keys()]) {
|
|
20
|
+
if (candidate === "" || candidate === dir || !candidate.startsWith(prefix)) continue;
|
|
21
|
+
const head = candidate.slice(prefix.length).split("/")[0];
|
|
22
|
+
if (head !== void 0 && head !== "") children.add(head);
|
|
23
|
+
}
|
|
24
|
+
return [...children];
|
|
25
|
+
};
|
|
26
|
+
const writeOrCompare = Effect.fn("okfit/sync/index/writeOrCompare")(function* (targetPath, relativeId, rendered, dryRun, written, unchanged) {
|
|
27
|
+
if ((yield* (yield* FileSystem.FileSystem).readFileString(targetPath).pipe(Effect.map((text) => text), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(void 0)))) === rendered) {
|
|
28
|
+
unchanged.push(relativeId);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (dryRun) {
|
|
32
|
+
written.push(relativeId);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
yield* writeAtomic(targetPath, rendered);
|
|
36
|
+
written.push(relativeId);
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Contract §7. Directories: the bundle root plus every directory in
|
|
40
|
+
* `bundle.directories` that holds at least one concept -- the same set
|
|
41
|
+
* `Derive.synthesizeIndex` already walks (S-20). A directory with an
|
|
42
|
+
* `index.md` but no concepts is left untouched (design §4: "nothing to
|
|
43
|
+
* derive") because such a directory is never a member of
|
|
44
|
+
* `bundle.directories` in the first place (`Bundle.load` only records a
|
|
45
|
+
* directory there when it holds a concept -- `Bundle.ts:301-323`).
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
const syncIndex = Effect.fn("okfit/sync/syncIndex")(function* (bundle, dryRun) {
|
|
50
|
+
const path = yield* Path.Path;
|
|
51
|
+
const written = [];
|
|
52
|
+
const unchanged = [];
|
|
53
|
+
const rootConcepts = [...bundle.concepts.values()].filter((concept) => !concept.path.includes("/"));
|
|
54
|
+
const rootRendered = Derive.renderIndex("", rootConcepts, {
|
|
55
|
+
okfVersion: OKF_SPEC_VERSION,
|
|
56
|
+
subdirectories: childDirectoriesOf(bundle, "")
|
|
57
|
+
});
|
|
58
|
+
yield* writeOrCompare(path.join(bundle.root, "index.md"), "index.md", rootRendered, dryRun, written, unchanged);
|
|
59
|
+
for (const dir of bundle.directories) {
|
|
60
|
+
if (dir === "") continue;
|
|
61
|
+
const rendered = Derive.synthesizeIndex(bundle, dir);
|
|
62
|
+
yield* writeOrCompare(path.join(bundle.root, dir, "index.md"), `${dir}/index.md`, rendered, dryRun, written, unchanged);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
selected: true,
|
|
66
|
+
written,
|
|
67
|
+
unchanged,
|
|
68
|
+
skipped: []
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { syncIndex };
|
package/sync/log.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { writeAtomic } from "./write.js";
|
|
2
|
+
import { DateTime, Effect, FileSystem, Path } from "effect";
|
|
3
|
+
import { Derive } from "@okfit/core";
|
|
4
|
+
|
|
5
|
+
//#region src/sync/log.ts
|
|
6
|
+
const byTitle = (a, b) => a.title < b.title ? -1 : a.title > b.title ? 1 : 0;
|
|
7
|
+
const renderItem = (item) => item.added ? `Added ${item.title}` : `Updated ${item.title}`;
|
|
8
|
+
/**
|
|
9
|
+
* S-10: takes STRUCTURED additions (`{date, title, added}`), not
|
|
10
|
+
* pre-rendered strings -- this is the corrected shape of CP §2's own
|
|
11
|
+
* proposal (contract §14 note 4): rendering "Added <title>"/
|
|
12
|
+
* "Updated <title>" happens INSIDE `mergeLog`, so "sort by title" (design
|
|
13
|
+
* §5 step 4) sorts the actual title field, never a substring of a
|
|
14
|
+
* pre-rendered line. Renders a NEW group's items via
|
|
15
|
+
* `Derive.renderLogEntry` (S-11's per-group renderer); an EXISTING
|
|
16
|
+
* group's text (heading and items) is re-emitted verbatim by slicing
|
|
17
|
+
* `existing.source` on the group's own `LogGroup.range` -- never
|
|
18
|
+
* re-serialised, so a hand-written item, comment, or unusual formatting
|
|
19
|
+
* inside an existing group survives byte-for-byte.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const mergeLog = (existing, additions) => {
|
|
24
|
+
const title = existing?.doc.title ?? "Log";
|
|
25
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
26
|
+
if (existing !== void 0) for (const group of existing.doc.groups) {
|
|
27
|
+
const verbatim = existing.source.slice(group.range.offset, group.range.offset + group.range.length);
|
|
28
|
+
byDate.set(group.date, {
|
|
29
|
+
verbatim,
|
|
30
|
+
pending: []
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
for (const addition of additions) {
|
|
34
|
+
const bucket = byDate.get(addition.date) ?? {
|
|
35
|
+
verbatim: void 0,
|
|
36
|
+
pending: []
|
|
37
|
+
};
|
|
38
|
+
bucket.pending.push({
|
|
39
|
+
title: addition.title,
|
|
40
|
+
added: addition.added
|
|
41
|
+
});
|
|
42
|
+
byDate.set(addition.date, bucket);
|
|
43
|
+
}
|
|
44
|
+
const dates = [...byDate.keys()].sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
45
|
+
const pieces = dates.map((date, index) => {
|
|
46
|
+
const bucket = byDate.get(date);
|
|
47
|
+
let piece;
|
|
48
|
+
if (bucket === void 0) piece = "";
|
|
49
|
+
else if (bucket.verbatim === void 0) {
|
|
50
|
+
const items = [...bucket.pending].sort(byTitle).map(renderItem);
|
|
51
|
+
piece = Derive.renderLogEntry({
|
|
52
|
+
date,
|
|
53
|
+
items
|
|
54
|
+
});
|
|
55
|
+
} else if (bucket.pending.length === 0) piece = bucket.verbatim;
|
|
56
|
+
else {
|
|
57
|
+
const trailingNewlines = /\n+$/.exec(bucket.verbatim)?.[0] ?? "";
|
|
58
|
+
const base = bucket.verbatim.slice(0, bucket.verbatim.length - trailingNewlines.length);
|
|
59
|
+
const trailer = trailingNewlines.slice(1);
|
|
60
|
+
piece = `${base}\n${[...bucket.pending].sort(byTitle).map((item) => `* ${renderItem(item)}\n`).join("")}${trailer}`;
|
|
61
|
+
}
|
|
62
|
+
return !(index === dates.length - 1) && !piece.endsWith("\n\n") ? `${piece}\n` : piece;
|
|
63
|
+
});
|
|
64
|
+
return dates.length === 0 ? `# ${title}\n` : `# ${title}\n\n${pieces.join("")}`;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Contract §8.2. Only the root `log.md`; `bundle.concepts` is the only
|
|
68
|
+
* thing scanned -- `index.md`/`log.md` are reserved files, never concepts
|
|
69
|
+
* (S-22, contract §14 note 8 -- closes PB's Open question 1 outright: an
|
|
70
|
+
* `Updated index`/`Updated log` line can never occur).
|
|
71
|
+
*
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
const syncLog = Effect.fn("okfit/sync/syncLog")(function* (bundle, provenance, dryRun) {
|
|
75
|
+
const fs = yield* FileSystem.FileSystem;
|
|
76
|
+
const logPath = (yield* Path.Path).join(bundle.root, "log.md");
|
|
77
|
+
const source = yield* fs.readFileString(logPath).pipe(Effect.map((text) => text), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(void 0)));
|
|
78
|
+
const existingDoc = bundle.logs.get("");
|
|
79
|
+
if (source !== void 0 && existingDoc === void 0) return {
|
|
80
|
+
selected: true,
|
|
81
|
+
written: [],
|
|
82
|
+
unchanged: [],
|
|
83
|
+
skipped: [{
|
|
84
|
+
id: "log.md",
|
|
85
|
+
reason: "log-unparseable"
|
|
86
|
+
}]
|
|
87
|
+
};
|
|
88
|
+
const newestLogged = (existingDoc?.groups ?? []).map((entryGroup) => entryGroup.date).reduce((max, date) => max === void 0 || date > max ? date : max, void 0);
|
|
89
|
+
const additions = [];
|
|
90
|
+
for (const [id, concept] of bundle.concepts) {
|
|
91
|
+
const derived = provenance.get(id);
|
|
92
|
+
if (derived === void 0 || derived._tag !== "committed") continue;
|
|
93
|
+
const date = DateTime.formatIsoDate(derived.at);
|
|
94
|
+
if (newestLogged !== void 0 && date <= newestLogged) continue;
|
|
95
|
+
additions.push({
|
|
96
|
+
date,
|
|
97
|
+
title: Derive.title(concept),
|
|
98
|
+
added: derived.creating
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const merged = mergeLog(source === void 0 || existingDoc === void 0 ? void 0 : {
|
|
102
|
+
doc: existingDoc,
|
|
103
|
+
source
|
|
104
|
+
}, additions);
|
|
105
|
+
if (merged === (source ?? "")) return {
|
|
106
|
+
selected: true,
|
|
107
|
+
written: [],
|
|
108
|
+
unchanged: ["log.md"],
|
|
109
|
+
skipped: []
|
|
110
|
+
};
|
|
111
|
+
if (dryRun) return {
|
|
112
|
+
selected: true,
|
|
113
|
+
written: ["log.md"],
|
|
114
|
+
unchanged: [],
|
|
115
|
+
skipped: []
|
|
116
|
+
};
|
|
117
|
+
yield* writeAtomic(logPath, merged);
|
|
118
|
+
return {
|
|
119
|
+
selected: true,
|
|
120
|
+
written: ["log.md"],
|
|
121
|
+
unchanged: [],
|
|
122
|
+
skipped: []
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
export { mergeLog, syncLog };
|
package/sync/run.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { syncGenerated } from "./generated.js";
|
|
2
|
+
import { syncIndex } from "./index.js";
|
|
3
|
+
import { syncLog } from "./log.js";
|
|
4
|
+
import { Effect, Schema } from "effect";
|
|
5
|
+
import { Bundle } from "@okfit/core";
|
|
6
|
+
import { Derivation } from "@okfit/profiles";
|
|
7
|
+
|
|
8
|
+
//#region src/sync/run.ts
|
|
9
|
+
/**
|
|
10
|
+
* S-14: a closed union, not free text. The human renderer maps each member
|
|
11
|
+
* to one fixed sentence (`render/sync.ts#humanSync`); the JSON envelope's
|
|
12
|
+
* `skipped[].reason` is this exact union.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const SkipReason = Schema.Literals([
|
|
17
|
+
"untracked",
|
|
18
|
+
"dirty",
|
|
19
|
+
"unborn",
|
|
20
|
+
"generated-missing",
|
|
21
|
+
"generated-unsupported",
|
|
22
|
+
"log-unparseable"
|
|
23
|
+
]);
|
|
24
|
+
/** A selected-`false` placeholder for a mode `--only` excluded from (contract §5). */
|
|
25
|
+
const UNSELECTED = {
|
|
26
|
+
selected: false,
|
|
27
|
+
written: [],
|
|
28
|
+
unchanged: [],
|
|
29
|
+
skipped: []
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Contract §5's six-step algorithm: one `Bundle.load`, one
|
|
33
|
+
* `Derivation.generatedAt` per concept shared by the generated and log
|
|
34
|
+
* modes (skipped entirely when neither is selected), then the three modes
|
|
35
|
+
* in a FIXED order — generated, index, log — regardless of `--only`'s own
|
|
36
|
+
* occurrence order, so a generated write never invalidates an index or log
|
|
37
|
+
* rendered in the same run (design §2).
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
const runSync = Effect.fn("okfit/sync/runSync")(function* (options) {
|
|
42
|
+
const bundle = yield* Bundle.load({ root: options.bundleRoot });
|
|
43
|
+
const needsGit = options.modes.has("generated") || options.modes.has("log");
|
|
44
|
+
const provenance = /* @__PURE__ */ new Map();
|
|
45
|
+
if (needsGit) for (const [id, concept] of bundle.concepts) {
|
|
46
|
+
const derived = yield* Derivation.generatedAt({ file: `${bundle.root}/${concept.path}` });
|
|
47
|
+
provenance.set(id, derived);
|
|
48
|
+
}
|
|
49
|
+
const generated = options.modes.has("generated") ? {
|
|
50
|
+
selected: true,
|
|
51
|
+
...yield* syncGenerated(bundle, provenance, options.dryRun)
|
|
52
|
+
} : UNSELECTED;
|
|
53
|
+
const index = options.modes.has("index") ? yield* syncIndex(bundle, options.dryRun) : UNSELECTED;
|
|
54
|
+
const log = options.modes.has("log") ? yield* syncLog(bundle, provenance, options.dryRun) : UNSELECTED;
|
|
55
|
+
return {
|
|
56
|
+
bundleRoot: options.bundleRoot,
|
|
57
|
+
dryRun: options.dryRun,
|
|
58
|
+
generated,
|
|
59
|
+
index,
|
|
60
|
+
log
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { SkipReason, runSync };
|
package/sync/write.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/sync/write.ts
|
|
5
|
+
/**
|
|
6
|
+
* S-33: the one atomic write every sync mode shares. Resolves `target`'s
|
|
7
|
+
* real path first when it already exists (so a symlinked
|
|
8
|
+
* `index.md`/`log.md`/concept file is replaced by writing through the
|
|
9
|
+
* link rather than over it -- `generated` mode's own discipline, formerly
|
|
10
|
+
* not shared with `index`/`log`) and falls back to `target` itself for a
|
|
11
|
+
* file `sync` is about to create for the first time (`index`/`log` modes
|
|
12
|
+
* both write files that may not exist yet; `generated` mode's targets
|
|
13
|
+
* always exist, since they come from an already-loaded concept). Writes
|
|
14
|
+
* `contents` to a uniquely-scoped temp file beside the resolved target (so
|
|
15
|
+
* two concurrent `sync` runs never collide on a fixed name), preserves an
|
|
16
|
+
* existing target's mode via `stat` + `chmod` (a brand-new file keeps
|
|
17
|
+
* whatever mode `writeFileString` gives it), and renames the temp file
|
|
18
|
+
* over the target. On any failure -- including a failed `rename` -- the
|
|
19
|
+
* temp file is removed (`Effect.onError`), so a partial write never
|
|
20
|
+
* leaves a stray `<file>.okfit-sync.<token>.tmp` behind (F-5/S-33).
|
|
21
|
+
*
|
|
22
|
+
* DISCREPANCY from S-33's literal "the temp name carries the pid": `src/`
|
|
23
|
+
* outside `bin.ts`/`commands/*`/`internal/exit.ts`/`internal/tty.ts` never
|
|
24
|
+
* reads the global `process` (K-39, enforced by `__test__/boundaries.test.ts`),
|
|
25
|
+
* and `sync/write.ts` is not on that allowlist. A `randomUUID()` token
|
|
26
|
+
* (`node:crypto`, not `process`) satisfies S-33's actual requirement --
|
|
27
|
+
* two concurrent `sync` runs never collide on a fixed temp name -- without
|
|
28
|
+
* widening the process-access boundary for one helper. Recorded here per
|
|
29
|
+
* the Global Constraints' "record the discrepancy" instruction.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
const writeAtomic = Effect.fn("okfit/sync/writeAtomic")(function* (target, contents) {
|
|
34
|
+
const fs = yield* FileSystem.FileSystem;
|
|
35
|
+
const path = yield* Path.Path;
|
|
36
|
+
const resolved = yield* fs.realPath(target).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(target)));
|
|
37
|
+
const dir = path.dirname(resolved);
|
|
38
|
+
const base = path.basename(resolved);
|
|
39
|
+
const tempPath = path.join(dir, `${base}.okfit-sync.${randomUUID()}.tmp`);
|
|
40
|
+
const originalMode = yield* fs.stat(resolved).pipe(Effect.map((info) => Option.some(info.mode)), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(Option.none())));
|
|
41
|
+
yield* fs.writeFileString(tempPath, contents).pipe(Effect.tap(() => Option.match(originalMode, {
|
|
42
|
+
onNone: () => Effect.void,
|
|
43
|
+
onSome: (mode) => fs.chmod(tempPath, mode)
|
|
44
|
+
})), Effect.tap(() => fs.rename(tempPath, resolved)), Effect.onError(() => fs.remove(tempPath, { force: true }).pipe(Effect.ignore)));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { writeAtomic };
|
package/validate/run.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Context, Effect, Option } from "effect";
|
|
2
|
+
import { Bundle, OkfitConfig, Validate } from "@okfit/core";
|
|
3
|
+
import { Provenance } from "@okfit/profiles";
|
|
4
|
+
|
|
5
|
+
//#region src/validate/run.ts
|
|
6
|
+
/**
|
|
7
|
+
* K-47's ambient clock. `bin.ts` resolves `OKFIT_NOW` (or the wall clock)
|
|
8
|
+
* exactly once and provides it with `Effect.provideService(Now, value)`
|
|
9
|
+
* around the whole command tree; `commands/validate.ts` and
|
|
10
|
+
* `commands/init.ts` each read it with `const now = yield* Now;` before
|
|
11
|
+
* building {@link RunOptions} or a scaffold's `today`. Not re-exported from
|
|
12
|
+
* `index.ts`: like `internal/exit.ts` and `internal/tty.ts`, it means
|
|
13
|
+
* nothing outside a spawned process (K-49). `Context.Tag` does not exist on
|
|
14
|
+
* the Effect v4 line; the v4 shape is
|
|
15
|
+
* `Context.Service<Self, Shape>()(id)`, the same form core and profiles use
|
|
16
|
+
* for their own services (`PROFILES/GitHistory.ts:140`) — source wins over
|
|
17
|
+
* the contract's literal snippet here (K-62).
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
var Now = class extends Context.Service()("@okfit/cli/Now") {};
|
|
22
|
+
/**
|
|
23
|
+
* Load, validate both tiers, run the profile check, then — UNLESS the
|
|
24
|
+
* `generated-at-drift` lint is `off` OR `options.skipProvenance` is `true`
|
|
25
|
+
* — run `Provenance.lint` and append its `Diagnostic`s to `report.lint`.
|
|
26
|
+
* The "skip the git walk entirely" gate lives HERE, in `run`, not inside
|
|
27
|
+
* `Provenance.lint` (S-8's own wording): a bundle configured `off`, or a
|
|
28
|
+
* caller that passed `--skip-provenance`, never pays for a git spawn
|
|
29
|
+
* (S-31). An `error` severity on the appended diagnostics yields exit `1`
|
|
30
|
+
* through the EXISTING lint-tier rule in `render/exit.ts` — no renderer
|
|
31
|
+
* branch, no new `DiagnosticSource`, since these diagnostics flow through
|
|
32
|
+
* the same `report.lint` array every other core lint diagnostic already
|
|
33
|
+
* does.
|
|
34
|
+
*
|
|
35
|
+
* K-52 holds structurally: `Bundle.load`'s failure short-circuits the
|
|
36
|
+
* generator, so `profile.check` never runs on a bundle that did not load.
|
|
37
|
+
*
|
|
38
|
+
* This is a BREAKING signature change: `@okfit/mcp`'s `validateBundle.ts`
|
|
39
|
+
* calls this function directly and must be updated to match (Task C1);
|
|
40
|
+
* `commands/validate.ts` is the other caller.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
const run = (options) => Effect.gen(function* () {
|
|
45
|
+
const bundle = yield* Bundle.load({ root: options.root });
|
|
46
|
+
const report = Validate.all(bundle, options.config, { now: options.now });
|
|
47
|
+
const profileDiagnostics = Option.match(options.profile, {
|
|
48
|
+
onNone: () => [],
|
|
49
|
+
onSome: (profile) => profile.check(bundle)
|
|
50
|
+
});
|
|
51
|
+
const provenance = OkfitConfig.severityFor(options.config, "generated-at-drift") === "off" || options.skipProvenance === true ? [] : yield* Provenance.lint(bundle, options.config);
|
|
52
|
+
return {
|
|
53
|
+
bundle,
|
|
54
|
+
report: {
|
|
55
|
+
...report,
|
|
56
|
+
lint: [...report.lint, ...provenance]
|
|
57
|
+
},
|
|
58
|
+
profileDiagnostics
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { Now, run };
|