@okfit/cli 0.2.0 → 0.4.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 +10 -7
- package/bin/okfit.js +2 -51
- package/commands/context.js +3 -6
- package/commands/init.js +3 -9
- package/commands/sync.js +3 -6
- package/commands/validate.js +3 -8
- package/commands/verify.js +3 -7
- package/errors.js +2 -105
- package/index.d.ts +25 -769
- package/index.js +4 -13
- package/main.d.ts +15 -0
- package/main.js +44 -0
- package/package.json +9 -6
- package/render/context.js +1 -80
- package/render/human.js +1 -1
- package/render/sync.js +1 -61
- package/render/verify.js +1 -44
- package/version.js +6 -6
- package/config/anchor.js +0 -57
- package/config/layer.js +0 -129
- package/config/resolve.js +0 -67
- package/context/run.js +0 -35
- package/init/scaffold.js +0 -174
- package/package.js +0 -5
- package/render/exit.js +0 -53
- package/render/json.js +0 -126
- package/render/sort.js +0 -46
- package/sync/generated.js +0 -111
- package/sync/index.js +0 -73
- package/sync/log.js +0 -127
- package/sync/run.js +0 -65
- package/sync/write.js +0 -48
- package/validate/run.js +0 -63
- package/verify/locate.js +0 -249
- package/verify/run.js +0 -106
- package/verify/splice.js +0 -75
package/sync/log.js
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
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 };
|
package/verify/locate.js
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
import { FrontmatterSource } from "@effected/markdown";
|
|
3
|
-
import { YamlAlias, YamlDocument, YamlMap, YamlScalar, YamlSeq } from "@effected/yaml";
|
|
4
|
-
|
|
5
|
-
//#region src/verify/locate.ts
|
|
6
|
-
/**
|
|
7
|
-
* Strip a leading BOM before anything else; the caller re-prepends `bom`
|
|
8
|
-
* verbatim on write (V-15). `FrontmatterSource.split`'s own remarks require
|
|
9
|
-
* this: "a leading BOM means offset 0 is not a fence — strip one before
|
|
10
|
-
* calling" (`FrontmatterSource.ts:123`).
|
|
11
|
-
*
|
|
12
|
-
* @internal
|
|
13
|
-
*/
|
|
14
|
-
const stripBom = (source) => source.charCodeAt(0) === 65279 ? {
|
|
15
|
-
text: source.slice(1),
|
|
16
|
-
bom: ""
|
|
17
|
-
} : {
|
|
18
|
-
text: source,
|
|
19
|
-
bom: ""
|
|
20
|
-
};
|
|
21
|
-
/** CRLF when the document's FIRST newline is CRLF, else LF (V-15). @internal */
|
|
22
|
-
const detectNewline = (source) => {
|
|
23
|
-
const index = source.indexOf("\n");
|
|
24
|
-
return index > 0 && source.charCodeAt(index - 1) === 13 ? "\r\n" : "\n";
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* The terminator the spliced fragment must use: `split`'s own fidelity
|
|
28
|
-
* field for the opening fence's terminator when it is LF or CRLF, else
|
|
29
|
-
* {@link detectNewline}. `FrontmatterNewline` also admits a lone `"\r"`,
|
|
30
|
-
* which no fragment may emit, so that case falls through to the scan.
|
|
31
|
-
*
|
|
32
|
-
* @internal
|
|
33
|
-
*/
|
|
34
|
-
const documentNewline = (text) => {
|
|
35
|
-
const newline = FrontmatterSource.split(text).frontmatter?.newline;
|
|
36
|
-
if (newline === "\r\n") return "\r\n";
|
|
37
|
-
if (newline === "\n") return "\n";
|
|
38
|
-
return detectNewline(text);
|
|
39
|
-
};
|
|
40
|
-
/** The run of spaces ending at `offset`, or `""` when anything else precedes it on the line. @internal */
|
|
41
|
-
const indentAt = (value, offset) => {
|
|
42
|
-
const lineStart = value.lastIndexOf("\n", offset - 1) + 1;
|
|
43
|
-
const prefix = value.slice(lineStart, offset);
|
|
44
|
-
return /^ *$/.test(prefix) ? prefix : "";
|
|
45
|
-
};
|
|
46
|
-
/**
|
|
47
|
-
* True when `node`, or any mapping directly inside a sequence `node`,
|
|
48
|
-
* carries a `<<` key. That is every level a legal `Verification.List` can
|
|
49
|
-
* reach; anything deeper already fails to decode as a `Verification`
|
|
50
|
-
* (contract §11 item 3).
|
|
51
|
-
*/
|
|
52
|
-
const hasMergeKey = (node) => {
|
|
53
|
-
if (node instanceof YamlMap) return node.items.some((pair) => pair.key instanceof YamlScalar && pair.key.value === "<<");
|
|
54
|
-
if (node instanceof YamlSeq) return node.items.some((item) => item instanceof YamlMap && hasMergeKey(item));
|
|
55
|
-
return false;
|
|
56
|
-
};
|
|
57
|
-
/**
|
|
58
|
-
* Split `source`'s frontmatter, parse its value, find the top-level
|
|
59
|
-
* `verified` pair, and classify it (contract §3.1). Fails only when the
|
|
60
|
-
* frontmatter YAML is fatally unparseable — impossible for a concept that
|
|
61
|
-
* reached `bundle.concepts`, but typed rather than assumed.
|
|
62
|
-
*
|
|
63
|
-
* Offset translation is `valueStart = 3 + newline.length` — `"---".length`
|
|
64
|
-
* plus the opening fence's own terminator, taken from `split`'s fidelity
|
|
65
|
-
* field rather than re-derived with `indexOf` (contract §12 note 9). Every
|
|
66
|
-
* whole-file offset below is `valueStart + <yaml offset>`.
|
|
67
|
-
*
|
|
68
|
-
* @internal
|
|
69
|
-
*/
|
|
70
|
-
const locate = Effect.fn("okfit/verify/locate")(function* (source) {
|
|
71
|
-
const block = FrontmatterSource.split(source).frontmatter;
|
|
72
|
-
if (block === void 0) return {
|
|
73
|
-
_tag: "unsupported",
|
|
74
|
-
shape: "no-frontmatter"
|
|
75
|
-
};
|
|
76
|
-
const value = block.value;
|
|
77
|
-
const valueStart = 3 + (block.newline ?? "\n").length;
|
|
78
|
-
const contents = (yield* YamlDocument.parse(value)).contents;
|
|
79
|
-
if (!(contents instanceof YamlMap)) return {
|
|
80
|
-
_tag: "unsupported",
|
|
81
|
-
shape: "not-a-mapping"
|
|
82
|
-
};
|
|
83
|
-
const pair = contents.items.find((item) => item.key instanceof YamlScalar && item.key.value === "verified");
|
|
84
|
-
if (pair === void 0) return {
|
|
85
|
-
_tag: "absent",
|
|
86
|
-
insertAt: valueStart + value.length
|
|
87
|
-
};
|
|
88
|
-
const key = pair.key;
|
|
89
|
-
const node = pair.value;
|
|
90
|
-
if (node === null) return {
|
|
91
|
-
_tag: "unsupported",
|
|
92
|
-
shape: "empty"
|
|
93
|
-
};
|
|
94
|
-
if (node instanceof YamlAlias) return {
|
|
95
|
-
_tag: "unsupported",
|
|
96
|
-
shape: "alias"
|
|
97
|
-
};
|
|
98
|
-
if (hasMergeKey(node)) return {
|
|
99
|
-
_tag: "unsupported",
|
|
100
|
-
shape: "merge-key"
|
|
101
|
-
};
|
|
102
|
-
if (node instanceof YamlScalar) return {
|
|
103
|
-
_tag: "unsupported",
|
|
104
|
-
shape: "scalar"
|
|
105
|
-
};
|
|
106
|
-
if (node instanceof YamlSeq) {
|
|
107
|
-
if (node.style === "flow") return {
|
|
108
|
-
_tag: "flowSeq",
|
|
109
|
-
insertAt: valueStart + node.offset + node.length - 1,
|
|
110
|
-
empty: node.items.length === 0
|
|
111
|
-
};
|
|
112
|
-
const last = node.items[node.items.length - 1];
|
|
113
|
-
if (last === void 0) return {
|
|
114
|
-
_tag: "unsupported",
|
|
115
|
-
shape: "empty"
|
|
116
|
-
};
|
|
117
|
-
const end = last.offset + last.length;
|
|
118
|
-
return {
|
|
119
|
-
_tag: "blockSeq",
|
|
120
|
-
insertAt: valueStart + end,
|
|
121
|
-
afterNewline: value[end - 1] === "\n",
|
|
122
|
-
indent: indentAt(value, node.offset),
|
|
123
|
-
lastItemStyle: last instanceof YamlMap && last.style === "flow" ? "flow" : "block"
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
if (node instanceof YamlMap) {
|
|
127
|
-
const start = valueStart + key.offset + key.length + 1;
|
|
128
|
-
const rawEnd = node.offset + node.length;
|
|
129
|
-
const raw = value.slice(node.offset, rawEnd);
|
|
130
|
-
const original = raw.replace(/(?:\r\n|\n|\r)$/, "");
|
|
131
|
-
return {
|
|
132
|
-
_tag: "bareMapping",
|
|
133
|
-
style: node.style === "flow" ? "flow" : "block",
|
|
134
|
-
start,
|
|
135
|
-
end: valueStart + rawEnd,
|
|
136
|
-
indent: `${indentAt(value, key.offset)} `,
|
|
137
|
-
original,
|
|
138
|
-
endsWithNewline: original.length !== raw.length
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
_tag: "unsupported",
|
|
143
|
-
shape: "not-a-mapping"
|
|
144
|
-
};
|
|
145
|
-
});
|
|
146
|
-
/**
|
|
147
|
-
* Locate the `at` key inside a concept's top-level `generated` BLOCK
|
|
148
|
-
* mapping. Callers MUST have already confirmed `generated` exists on the
|
|
149
|
-
* decoded concept (`generated-missing`, S-3/design §3 rule 5, is the
|
|
150
|
-
* caller's check against `concept.frontmatter.generated`, never this
|
|
151
|
-
* function's) — matches how `locate` already re-parses independently of
|
|
152
|
-
* whatever `Bundle.load` retained, for `verified`. The insert style is
|
|
153
|
-
* one new newline-terminated line after the last key's line of the
|
|
154
|
-
* mapping, at that mapping's indent, using the document newline (S-2,
|
|
155
|
-
* confirmed as already correct in FW's own proposal — contract §14 note
|
|
156
|
-
* 1 — with no correction needed against this file's `locate` precedent).
|
|
157
|
-
*
|
|
158
|
-
* @internal
|
|
159
|
-
*/
|
|
160
|
-
const locateGenerated = Effect.fn("okfit/verify/locateGenerated")(function* (source) {
|
|
161
|
-
const block = FrontmatterSource.split(source).frontmatter;
|
|
162
|
-
if (block === void 0) return {
|
|
163
|
-
_tag: "unsupported",
|
|
164
|
-
shape: "no-frontmatter"
|
|
165
|
-
};
|
|
166
|
-
const value = block.value;
|
|
167
|
-
const valueStart = 3 + (block.newline ?? "\n").length;
|
|
168
|
-
const contents = (yield* YamlDocument.parse(value)).contents;
|
|
169
|
-
if (!(contents instanceof YamlMap)) return {
|
|
170
|
-
_tag: "unsupported",
|
|
171
|
-
shape: "not-a-mapping"
|
|
172
|
-
};
|
|
173
|
-
const pair = contents.items.find((item) => item.key instanceof YamlScalar && item.key.value === "generated");
|
|
174
|
-
if (pair === void 0) return {
|
|
175
|
-
_tag: "unsupported",
|
|
176
|
-
shape: "no-generated-key"
|
|
177
|
-
};
|
|
178
|
-
const node = pair.value;
|
|
179
|
-
if (node === null) return {
|
|
180
|
-
_tag: "unsupported",
|
|
181
|
-
shape: "empty"
|
|
182
|
-
};
|
|
183
|
-
if (node instanceof YamlAlias) return {
|
|
184
|
-
_tag: "unsupported",
|
|
185
|
-
shape: "alias"
|
|
186
|
-
};
|
|
187
|
-
if (hasMergeKey(node)) return {
|
|
188
|
-
_tag: "unsupported",
|
|
189
|
-
shape: "merge-key"
|
|
190
|
-
};
|
|
191
|
-
if (node instanceof YamlScalar) return {
|
|
192
|
-
_tag: "unsupported",
|
|
193
|
-
shape: "scalar"
|
|
194
|
-
};
|
|
195
|
-
if (node instanceof YamlSeq) return {
|
|
196
|
-
_tag: "unsupported",
|
|
197
|
-
shape: "sequence"
|
|
198
|
-
};
|
|
199
|
-
if (!(node instanceof YamlMap)) return {
|
|
200
|
-
_tag: "unsupported",
|
|
201
|
-
shape: "not-a-mapping"
|
|
202
|
-
};
|
|
203
|
-
if (node.style === "flow") return {
|
|
204
|
-
_tag: "unsupported",
|
|
205
|
-
shape: "flow-mapping"
|
|
206
|
-
};
|
|
207
|
-
const indent = indentAt(value, node.offset);
|
|
208
|
-
const atPair = node.items.find((item) => item.key instanceof YamlScalar && item.key.value === "at");
|
|
209
|
-
if (atPair === void 0) {
|
|
210
|
-
const last = node.items[node.items.length - 1];
|
|
211
|
-
if (last === void 0) return {
|
|
212
|
-
_tag: "unsupported",
|
|
213
|
-
shape: "empty"
|
|
214
|
-
};
|
|
215
|
-
const lastEnd = last.value !== null ? last.value.offset + last.value.length : last.key.offset + last.key.length;
|
|
216
|
-
const nl = value.indexOf("\n", lastEnd);
|
|
217
|
-
return {
|
|
218
|
-
_tag: "insertAfterLastKey",
|
|
219
|
-
insertAt: valueStart + (nl === -1 ? value.length : nl + 1),
|
|
220
|
-
indent
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
const atValue = atPair.value;
|
|
224
|
-
if (atValue === null) return {
|
|
225
|
-
_tag: "unsupported",
|
|
226
|
-
shape: "at-empty"
|
|
227
|
-
};
|
|
228
|
-
if (atValue instanceof YamlAlias) return {
|
|
229
|
-
_tag: "unsupported",
|
|
230
|
-
shape: "alias"
|
|
231
|
-
};
|
|
232
|
-
if (!(atValue instanceof YamlScalar)) return {
|
|
233
|
-
_tag: "unsupported",
|
|
234
|
-
shape: "at-not-scalar"
|
|
235
|
-
};
|
|
236
|
-
if (atValue.style === "block-literal" || atValue.style === "block-folded") return {
|
|
237
|
-
_tag: "unsupported",
|
|
238
|
-
shape: "at-block-scalar"
|
|
239
|
-
};
|
|
240
|
-
return {
|
|
241
|
-
_tag: "replaceScalar",
|
|
242
|
-
start: valueStart + atValue.offset,
|
|
243
|
-
end: valueStart + atValue.offset + atValue.length,
|
|
244
|
-
quote: atValue.style
|
|
245
|
-
};
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
//#endregion
|
|
249
|
-
export { detectNewline, documentNewline, indentAt, locate, locateGenerated, stripBom };
|
package/verify/run.js
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { VerifyConceptNotFoundError, VerifyUnsupportedFrontmatterError } from "../errors.js";
|
|
2
|
-
import { documentNewline, locate, stripBom } from "./locate.js";
|
|
3
|
-
import { splice } from "./splice.js";
|
|
4
|
-
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
5
|
-
import { Bundle, ConceptId, Timestamp } from "@okfit/core";
|
|
6
|
-
import { Derivation } from "@okfit/profiles";
|
|
7
|
-
import { MarkdownEdit } from "@effected/markdown";
|
|
8
|
-
|
|
9
|
-
//#region src/verify/run.ts
|
|
10
|
-
/**
|
|
11
|
-
* The only four diagnostic codes that can co-occur with a MISSING
|
|
12
|
-
* `concepts` entry (V-9, contract §12 note 10): `Bundle.ts:143/164/175/186`
|
|
13
|
-
* emit the three frontmatter codes and return without a concept, and
|
|
14
|
-
* `internal/frontmatter.ts:73` emits `type-missing` for `decodeConcept`'s
|
|
15
|
-
* only failure branch. Every other code — `family-invalid`,
|
|
16
|
-
* `computation-runtime-missing`, `legacy-timestamp` — is pushed as an issue
|
|
17
|
-
* while `decodeConcept` still returns a concept, so the concept IS in the
|
|
18
|
-
* map (`internal/conceptDecode.ts:84-134`).
|
|
19
|
-
*/
|
|
20
|
-
const UNDECODABLE_CODES = /* @__PURE__ */ new Set([
|
|
21
|
-
"frontmatter-unparseable",
|
|
22
|
-
"frontmatter-unclosed",
|
|
23
|
-
"frontmatter-missing",
|
|
24
|
-
"type-missing"
|
|
25
|
-
]);
|
|
26
|
-
/**
|
|
27
|
-
* Contract §2.3 steps 6–15. Loads the bundle, resolves the id, resolves the
|
|
28
|
-
* human actor from git, splices exactly one entry into the concept's
|
|
29
|
-
* frontmatter and writes it back atomically.
|
|
30
|
-
*
|
|
31
|
-
* Nothing here reads `status`, `stale_after`, or a type's
|
|
32
|
-
* `require_verified`: `verify` is mechanical, not config-aware (V-4, V-5).
|
|
33
|
-
*
|
|
34
|
-
* @internal
|
|
35
|
-
*/
|
|
36
|
-
const runVerify = Effect.fn("okfit/verify/runVerify")(function* (options) {
|
|
37
|
-
const fs = yield* FileSystem.FileSystem;
|
|
38
|
-
const path = yield* Path.Path;
|
|
39
|
-
const bundle = yield* Bundle.load({ root: options.bundleRoot });
|
|
40
|
-
const normalized = ConceptId.normalize(options.id);
|
|
41
|
-
if (Option.isNone(normalized)) return yield* new VerifyConceptNotFoundError({
|
|
42
|
-
id: options.id,
|
|
43
|
-
root: bundle.root,
|
|
44
|
-
reason: "not-a-concept"
|
|
45
|
-
});
|
|
46
|
-
const id = normalized.value;
|
|
47
|
-
const conceptFile = ConceptId.toPath(id);
|
|
48
|
-
if (ConceptId.isReservedFile(conceptFile)) return yield* new VerifyConceptNotFoundError({
|
|
49
|
-
id,
|
|
50
|
-
root: bundle.root,
|
|
51
|
-
reason: "reserved"
|
|
52
|
-
});
|
|
53
|
-
const concept = bundle.concepts.get(id);
|
|
54
|
-
if (concept === void 0) {
|
|
55
|
-
const diagnostic = bundle.diagnostics.find((entry) => entry.file === conceptFile && UNDECODABLE_CODES.has(entry.code));
|
|
56
|
-
return yield* new VerifyConceptNotFoundError({
|
|
57
|
-
id,
|
|
58
|
-
root: bundle.root,
|
|
59
|
-
...diagnostic === void 0 ? { reason: "not-a-concept" } : {
|
|
60
|
-
reason: "undecodable",
|
|
61
|
-
diagnosticCode: diagnostic.code
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
const actor = yield* Derivation.generatedBy({
|
|
66
|
-
writer: "human",
|
|
67
|
-
cwd: options.projectRoot,
|
|
68
|
-
config: options.config
|
|
69
|
-
});
|
|
70
|
-
const absolutePath = path.join(bundle.root, concept.path);
|
|
71
|
-
const source = yield* fs.readFileString(absolutePath);
|
|
72
|
-
const { text, bom } = stripBom(source);
|
|
73
|
-
const located = yield* locate(text);
|
|
74
|
-
if (located._tag === "unsupported") return yield* new VerifyUnsupportedFrontmatterError({
|
|
75
|
-
id,
|
|
76
|
-
shape: located.shape
|
|
77
|
-
});
|
|
78
|
-
const at = Schema.encodeSync(Timestamp)(options.at);
|
|
79
|
-
const priorAt = (concept.frontmatter.verified ?? []).filter((entry) => entry.by === actor).map((entry) => Schema.encodeSync(Timestamp)(entry.at));
|
|
80
|
-
const edit = splice(located, {
|
|
81
|
-
by: actor,
|
|
82
|
-
at
|
|
83
|
-
}, documentNewline(text));
|
|
84
|
-
const finalText = bom + MarkdownEdit.applyAll(text, [edit]);
|
|
85
|
-
if (!options.dryRun) {
|
|
86
|
-
const target = yield* fs.realPath(absolutePath);
|
|
87
|
-
const tempPath = `${target}.okfit-verify.tmp`;
|
|
88
|
-
const original = yield* fs.stat(target);
|
|
89
|
-
yield* fs.writeFileString(tempPath, finalText);
|
|
90
|
-
yield* fs.chmod(tempPath, original.mode);
|
|
91
|
-
yield* fs.rename(tempPath, target).pipe(Effect.onError(() => fs.remove(tempPath, { force: true }).pipe(Effect.ignore)));
|
|
92
|
-
}
|
|
93
|
-
return {
|
|
94
|
-
id,
|
|
95
|
-
bundleRoot: bundle.root,
|
|
96
|
-
conceptPath: concept.path,
|
|
97
|
-
by: actor,
|
|
98
|
-
at,
|
|
99
|
-
priorAt,
|
|
100
|
-
dryRun: options.dryRun,
|
|
101
|
-
fragment: edit.content
|
|
102
|
-
};
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
//#endregion
|
|
106
|
-
export { runVerify };
|