@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.
@@ -0,0 +1,249 @@
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 ADDED
@@ -0,0 +1,106 @@
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 };
@@ -0,0 +1,75 @@
1
+ import { MarkdownEdit } from "@effected/markdown";
2
+
3
+ //#region src/verify/splice.ts
4
+ /** Prefix two spaces to every line but the first, joining with `newline` (V-13). */
5
+ const reindent = (original, newline) => original.split(/\r\n|\n|\r/).map((line, index) => index === 0 ? line : ` ${line}`).join(newline);
6
+ /**
7
+ * Build the one edit for `target` (contract §3.2). Never calls a YAML
8
+ * stringifier: the only syntax emitted is `- `, `{`, `}`, `: `, `,`,
9
+ * spaces and `newline`.
10
+ *
11
+ * @internal
12
+ */
13
+ const splice = (target, entry, newline) => {
14
+ const flow = `{ by: ${entry.by}, at: ${entry.at} }`;
15
+ const block = (indent) => `${indent}- by: ${entry.by}${newline}${indent} at: ${entry.at}`;
16
+ switch (target._tag) {
17
+ case "absent": return MarkdownEdit.make({
18
+ offset: target.insertAt,
19
+ length: 0,
20
+ content: `verified:${newline} - by: ${entry.by}${newline} at: ${entry.at}${newline}`
21
+ });
22
+ case "blockSeq": {
23
+ const fragment = target.lastItemStyle === "flow" ? `${target.indent}- ${flow}` : block(target.indent);
24
+ return MarkdownEdit.make({
25
+ offset: target.insertAt,
26
+ length: 0,
27
+ content: target.afterNewline ? `${fragment}${newline}` : `${newline}${fragment}`
28
+ });
29
+ }
30
+ case "flowSeq": return MarkdownEdit.make({
31
+ offset: target.insertAt,
32
+ length: 0,
33
+ content: target.empty ? flow : `, ${flow}`
34
+ });
35
+ case "bareMapping": return target.style === "flow" ? MarkdownEdit.make({
36
+ offset: target.start,
37
+ length: target.end - target.start,
38
+ content: `${newline}${target.indent}- ${target.original}${newline}${target.indent}- ${flow}`
39
+ }) : MarkdownEdit.make({
40
+ offset: target.start,
41
+ length: target.end - target.start,
42
+ content: `${newline}${target.indent}- ${reindent(target.original, newline)}${newline}${block(target.indent)}${newline}`
43
+ });
44
+ }
45
+ };
46
+ /**
47
+ * Build the one edit for `target` (contract §6.1). Never calls a YAML
48
+ * stringifier: the only syntax emitted is the indent, `at: `, the
49
+ * preserved quote character (if any), and `newline`. `encodedAt` is
50
+ * already `Schema.encodeSync(Timestamp)`'d by the caller (S-1: splice,
51
+ * never `YamlFormat.modify` — it drops quote style and re-normalises line
52
+ * endings, V-12/V-15 violations `sync/generated.ts` cannot afford).
53
+ *
54
+ * @internal
55
+ */
56
+ const spliceGenerated = (target, encodedAt, newline) => {
57
+ switch (target._tag) {
58
+ case "insertAfterLastKey": return MarkdownEdit.make({
59
+ offset: target.insertAt,
60
+ length: 0,
61
+ content: `${target.indent}at: ${encodedAt}${newline}`
62
+ });
63
+ case "replaceScalar": {
64
+ const quoted = target.quote === "single-quoted" ? `'${encodedAt}'` : target.quote === "double-quoted" ? `"${encodedAt}"` : encodedAt;
65
+ return MarkdownEdit.make({
66
+ offset: target.start,
67
+ length: target.end - target.start,
68
+ content: quoted
69
+ });
70
+ }
71
+ }
72
+ };
73
+
74
+ //#endregion
75
+ export { splice, spliceGenerated };
package/version.js ADDED
@@ -0,0 +1,14 @@
1
+ import { version } from "./package.js";
2
+
3
+ //#region src/version.ts
4
+ /**
5
+ * The version string reported by `okfit --version`, read from the package's
6
+ * own manifest (K-32) so a release can never desync from the printed
7
+ * version. Consumed by `bin.ts` only.
8
+ *
9
+ * @public
10
+ */
11
+ const CLI_VERSION = version;
12
+
13
+ //#endregion
14
+ export { CLI_VERSION };