@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/index.js CHANGED
@@ -1,12 +1,17 @@
1
+ import { ConfigMalformedError, ConfigPathNotFoundError, InitOverwriteError, VerifyConceptNotFoundError, VerifyUnsupportedFrontmatterError, renderFailure } from "./errors.js";
2
+ import { buildConfigLayer, provideConfig } from "./config/layer.js";
3
+ import { resolveBundleRoot, resolveProjectRoot } from "./config/anchor.js";
4
+ import { DEFAULT_PROFILE_NAME, resolveProjectConfig } from "./config/resolve.js";
5
+ import { runContext } from "./context/run.js";
6
+ import { ContextEnvelope, ContextTag, ContextType, contextEnvelope, humanContext } from "./render/context.js";
7
+ import { forDiagnostics, tally } from "./render/exit.js";
8
+ import { collect, sort } from "./render/sort.js";
9
+ import { JsonDiagnostic, JsonEnvelope, JsonErrorEnvelope, JsonSummary, json, jsonError } from "./render/json.js";
10
+ import { CLI_VERSION } from "./version.js";
11
+ import { CONFIG_RELATIVE_PATH, configValue, files, targetPaths } from "./init/scaffold.js";
12
+ import { human, line, summary } from "./render/human.js";
13
+ import { run } from "./validate/run.js";
14
+ import { VerifyEnvelope, humanVerify, verifyEnvelope } from "./render/verify.js";
1
15
  import { rootCommand } from "./commands/root.js";
2
16
 
3
- //#region src/index.ts
4
- /**
5
- * The version string reported by `okfit --version`.
6
- *
7
- * @public
8
- */
9
- const CLI_VERSION = "0.0.0";
10
-
11
- //#endregion
12
- export { CLI_VERSION, rootCommand };
17
+ export { CLI_VERSION, CONFIG_RELATIVE_PATH, ConfigMalformedError, ConfigPathNotFoundError, ContextEnvelope, ContextTag, ContextType, DEFAULT_PROFILE_NAME, InitOverwriteError, JsonDiagnostic, JsonEnvelope, JsonErrorEnvelope, JsonSummary, VerifyConceptNotFoundError, VerifyEnvelope, VerifyUnsupportedFrontmatterError, buildConfigLayer, collect, configValue, contextEnvelope, files, forDiagnostics, human, humanContext, humanVerify, json, jsonError, line, provideConfig, renderFailure, resolveBundleRoot, resolveProjectConfig, resolveProjectRoot, rootCommand, run, runContext, sort, summary, tally, targetPaths, verifyEnvelope };
@@ -0,0 +1,174 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import { Concept, ConceptId, Derive, LoadedConcept, OKF_SPEC_VERSION } from "@okfit/core";
3
+ import { MarkdownDocument, MarkdownFrontmatter, MarkdownParseOptions, YamlFrontmatter } from "@effected/markdown";
4
+
5
+ //#region src/init/scaffold.ts
6
+ /** `.config/okfit.toml`, relative to the project root (C-10). @public */
7
+ const CONFIG_RELATIVE_PATH = ".config/okfit.toml";
8
+ /**
9
+ * C-1's three project-level names, in precedence order. `init` refuses when
10
+ * ANY of them already exists in the target directory (C-9), so a fresh
11
+ * `okfit init` beside a hand-written `.okfit.toml` cannot silently write a
12
+ * second config the discovery order then shadows.
13
+ *
14
+ * Scope is `projectRoot` only, never the upward chain: an ancestor's config
15
+ * is a legitimate discovery hit, not a collision.
16
+ *
17
+ * @public
18
+ */
19
+ const PROJECT_CONFIG_NAMES = [
20
+ ".okfit.toml",
21
+ "okfit.toml",
22
+ ".config/okfit.toml"
23
+ ];
24
+ /** C-22's directive, plus the blank line Tombi requires. @public */
25
+ const SCHEMA_DIRECTIVE = "#:schema https://raw.githubusercontent.com/spencerbeggs/okfit/main/schemas/config/okfit-1.0.0.json\n\n";
26
+ /**
27
+ * K-23: the THIN override only — never the merged config. `bundle.path` is
28
+ * the bundle root relative to the project root; `extensions: {}` satisfies
29
+ * `OkfitConfig`'s one non-optional field (`CORE/OkfitConfig.ts:164`).
30
+ * Everything else resolves from `DEFAULTS` and the profile at load time.
31
+ * `actors.agent` is deliberately absent (K-24, P-17).
32
+ *
33
+ * @public
34
+ */
35
+ const configValue = (options) => ({
36
+ bundle: {
37
+ path: options.bundlePath,
38
+ profile: options.profileName
39
+ },
40
+ extensions: {}
41
+ });
42
+ /**
43
+ * Every path `init` will create, absolute, in write order, for the K-28
44
+ * pre-flight: the three project-level config names, the bundle root's
45
+ * `index.md`/`log.md`/`project.md`, then one `index.md` per layout directory
46
+ * in `layout`'s own declared order — never re-sorted here. `Derive.renderIndex`
47
+ * sorts its own `Subdirectories` section independently (C13), so this
48
+ * function's order has no bearing on the rendered index text.
49
+ *
50
+ * @public
51
+ */
52
+ const targetPaths = (options) => [
53
+ ...PROJECT_CONFIG_NAMES.map((name) => `${options.projectRoot}/${name}`),
54
+ `${options.bundleRoot}/${options.layout.root.index}`,
55
+ `${options.bundleRoot}/${options.layout.root.log}`,
56
+ `${options.bundleRoot}/${options.layout.root.project}`,
57
+ ...options.layout.directories.map((directory) => `${options.bundleRoot}/${directory.directory}/${options.layout.root.index}`)
58
+ ];
59
+ /** Frontmatter capture on — the write seam's own precondition (`MD/index.d.ts:1907-1920`; `CORE/internal/reserved.ts:15` precedent). */
60
+ const PARSE_OPTIONS = MarkdownParseOptions.make({ frontmatter: true });
61
+ /**
62
+ * The four keys `project.md` actually carries (contract section 3.4's
63
+ * worked transcript) — deliberately NOT the full `Concept` class. `Concept`'s
64
+ * `extensions` and `raw` fields are required, not `optionalKey`
65
+ * (`CORE/Concept.ts:39-40`), so encoding a `Concept.make` value through
66
+ * `Concept` itself would serialize `extensions: {}` and `raw: {}` into the
67
+ * frontmatter block too (verified: `Schema.encodeSync(Concept)` against a
68
+ * `Concept.make` value with empty `extensions`/`raw` includes both keys,
69
+ * this session). `Bundle.load` re-decodes a file written with this narrower
70
+ * schema exactly as it would one written with the full class —
71
+ * `internal/conceptDecode.ts` derives `extensions`/`raw` from whatever keys
72
+ * the frontmatter block actually carries, never from what wrote it (verified
73
+ * with a `Bundle.load` round trip, this session) — so the narrower schema
74
+ * costs nothing at read time and matches the transcript byte-for-byte.
75
+ */
76
+ const ProjectFrontmatter = Schema.Struct({
77
+ type: Schema.Literal("Project"),
78
+ title: Schema.String,
79
+ description: Schema.String,
80
+ status: Schema.Literals([
81
+ "draft",
82
+ "stable",
83
+ "deprecated"
84
+ ])
85
+ });
86
+ const PROJECT_DESCRIPTION = "What this project is, its boundaries, and its non-goals.";
87
+ const capitalize = (name) => `${name.charAt(0).toUpperCase()}${name.slice(1)}`;
88
+ const projectBody = (title) => [
89
+ `# ${title}`,
90
+ "",
91
+ "## Purpose",
92
+ "",
93
+ "Describe what this project is for in one paragraph.",
94
+ "",
95
+ "## Boundaries",
96
+ "",
97
+ "Describe what this project owns and what it deliberately leaves to others.",
98
+ "",
99
+ "## Non-goals",
100
+ "",
101
+ "List what this project will not do, so a reader never infers it from silence.",
102
+ ""
103
+ ].join("\n");
104
+ /**
105
+ * The three root markdown files and the per-directory indexes (K-25 to
106
+ * K-27, K-59). `project.md`'s frontmatter is written with
107
+ * `MarkdownFrontmatter.setToString` — the INSERT path, since the body below
108
+ * carries no frontmatter block of its own (`MD/index.d.ts:1959`,
109
+ * `FrontmatterWriteError`'s own doc comment: "a document with no frontmatter
110
+ * capture is the insert path, not an error"). The root `index.md` is
111
+ * `Derive.renderIndex` over a `LoadedConcept` this function synthesizes from
112
+ * the just-rendered `project.md` text, re-parsed with `MarkdownDocument.parse`
113
+ * (K-59; `renderIndex` reads only `frontmatter.type`/`title`/`description`
114
+ * and `path`, never `document`, `CORE/Derive.ts:75-101`, so the synthesized
115
+ * value is faithful). Each per-directory `index.md` is the literal
116
+ * `# <Directory>` — NOT `Derive.renderIndex`, which returns the empty string
117
+ * for an empty concept list with no options (contract Judge notes item 2).
118
+ *
119
+ * @public
120
+ */
121
+ const files = (options) => Effect.gen(function* () {
122
+ const { bundleRoot, layout, profileName, projectTitle, today } = options;
123
+ const frontmatterData = {
124
+ type: "Project",
125
+ title: projectTitle,
126
+ description: PROJECT_DESCRIPTION,
127
+ status: "draft"
128
+ };
129
+ const draftDocument = yield* MarkdownDocument.parse(projectBody(projectTitle), PARSE_OPTIONS);
130
+ const projectText = yield* MarkdownFrontmatter.setToString(ProjectFrontmatter, YamlFrontmatter)(draftDocument, frontmatterData);
131
+ const projectDocument = yield* MarkdownDocument.parse(projectText, PARSE_OPTIONS);
132
+ const projectConcept = LoadedConcept.make({
133
+ id: Option.getOrThrow(ConceptId.fromPath("project.md")),
134
+ path: "project.md",
135
+ frontmatter: Concept.make({
136
+ ...frontmatterData,
137
+ extensions: {},
138
+ raw: frontmatterData
139
+ }),
140
+ document: projectDocument,
141
+ computationBody: Option.none()
142
+ });
143
+ const subdirectories = layout.directories.map((directory) => directory.directory);
144
+ const rootIndexText = Derive.renderIndex("", [projectConcept], {
145
+ okfVersion: OKF_SPEC_VERSION,
146
+ subdirectories
147
+ });
148
+ const logText = Derive.renderLogEntry({
149
+ date: today,
150
+ items: [`Initialized the bundle with the ${profileName} profile`]
151
+ });
152
+ const directoryFiles = layout.directories.map((directory) => ({
153
+ path: `${bundleRoot}/${directory.directory}/${layout.root.index}`,
154
+ contents: `# ${capitalize(directory.directory)}\n`
155
+ }));
156
+ return [
157
+ {
158
+ path: `${bundleRoot}/${layout.root.index}`,
159
+ contents: rootIndexText
160
+ },
161
+ {
162
+ path: `${bundleRoot}/${layout.root.log}`,
163
+ contents: logText
164
+ },
165
+ {
166
+ path: `${bundleRoot}/${layout.root.project}`,
167
+ contents: projectText
168
+ },
169
+ ...directoryFiles
170
+ ];
171
+ });
172
+
173
+ //#endregion
174
+ export { CONFIG_RELATIVE_PATH, PROJECT_CONFIG_NAMES, SCHEMA_DIRECTIVE, configValue, files, targetPaths };
@@ -0,0 +1,14 @@
1
+ //#region src/internal/exit.ts
2
+ /**
3
+ * K-7: exits `1` and `2` are a SUCCESSFUL run that found diagnostics, never
4
+ * an Effect failure. The handler writes the code here and returns `void`.
5
+ * The only writer of `process.exitCode` in this package.
6
+ *
7
+ * @public
8
+ */
9
+ const setExitCode = (code) => {
10
+ process.exitCode = code;
11
+ };
12
+
13
+ //#endregion
14
+ export { setExitCode };
@@ -0,0 +1,13 @@
1
+ //#region src/internal/tty.ts
2
+ /**
3
+ * K-19: exactly the framework's own colour rule. Evaluated once per run at
4
+ * the command boundary and passed to the renderers as a boolean, so every
5
+ * renderer stays pure. The only reader of `process.stdout.isTTY`/`NO_COLOR`
6
+ * in this package.
7
+ *
8
+ * @public
9
+ */
10
+ const useColor = () => process.stdout.isTTY === true && process.env.NO_COLOR !== "1";
11
+
12
+ //#endregion
13
+ export { useColor };
package/package.js ADDED
@@ -0,0 +1,5 @@
1
+ //#region package.json
2
+ var version = "0.2.0";
3
+
4
+ //#endregion
5
+ export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okfit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "The okfit command line: validate, lint, index, and inspect Open Knowledge Format (OKF) bundles.",
6
6
  "keywords": [
@@ -37,10 +37,22 @@
37
37
  "okfit": "bin/okfit.js"
38
38
  },
39
39
  "dependencies": {
40
- "@effect/platform-node": "4.0.0-rc.109",
41
- "@okfit/core": "0.1.0",
42
- "@okfit/profiles": "0.1.0",
43
- "effect": "4.0.0-rc.109"
40
+ "@effect/platform-node": "4.0.0-rc.112",
41
+ "@effected/app": "^0.15.0",
42
+ "@effected/cli": "^0.3.1",
43
+ "@effected/config-file": "^0.7.0",
44
+ "@effected/git": "^0.12.0",
45
+ "@effected/glob": "^0.5.0",
46
+ "@effected/jsonc": "^0.9.0",
47
+ "@effected/markdown": "^0.9.1",
48
+ "@effected/store": "^0.7.0",
49
+ "@effected/toml": "^0.6.0",
50
+ "@effected/walker": "^0.7.0",
51
+ "@effected/xdg": "^0.4.1",
52
+ "@effected/yaml": "^0.14.0",
53
+ "@okfit/core": "0.2.0",
54
+ "@okfit/profiles": "0.2.0",
55
+ "effect": "4.0.0-rc.112"
44
56
  },
45
57
  "engines": {
46
58
  "node": ">=24.11.0"
@@ -0,0 +1,108 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/render/context.ts
4
+ /** One `types[]` entry (M-15). @public */
5
+ const ContextType = Schema.Struct({
6
+ name: Schema.String,
7
+ description: Schema.NullOr(Schema.String),
8
+ guidance: Schema.NullOr(Schema.String)
9
+ });
10
+ /** One `tags[]` entry (M-15). @public */
11
+ const ContextTag = Schema.Struct({
12
+ name: Schema.String,
13
+ description: Schema.NullOr(Schema.String)
14
+ });
15
+ /**
16
+ * M-15's envelope: schema 1, snake_case, orientation data only. Distinct
17
+ * from `JsonEnvelope` (`render/json.ts`) — `context` never runs conformance
18
+ * or lint checks, so there is no `diagnostics` array and no `exit_code`
19
+ * field at all.
20
+ *
21
+ * Every field is `Schema.NullOr`, never `Schema.optionalKey`: a consumer
22
+ * (the two hook scripts) reads a fixed key set and gets JSON `null` for an
23
+ * absent value, rather than having to distinguish a missing key from a
24
+ * null one. This is a deliberate difference from `JsonDiagnostic`'s
25
+ * `range`, which is `optionalKey` and omitted when absent
26
+ * (`render/json.ts:14,68`).
27
+ *
28
+ * `profile_requested` (final-review Important 1) is the profile name the
29
+ * config asked for after the K-4 default rule — `resolveProjectConfig`'s
30
+ * own `profileName` — and is `null` only when no config file was found at
31
+ * all. `profile` keeps its original meaning (the resolved profile's name,
32
+ * or `null` when the requested name is unknown or `"none"`). The two
33
+ * differ exactly when a config named an unrecognised profile: `profile`
34
+ * is `null` but `profile_requested` still names what was asked for, so a
35
+ * consumer can tell "no profile configured" apart from "an unknown profile
36
+ * was configured".
37
+ *
38
+ * @public
39
+ */
40
+ const ContextEnvelope = Schema.Struct({
41
+ schema: Schema.Literal(1),
42
+ project_root: Schema.String,
43
+ bundle_root: Schema.String,
44
+ config_path: Schema.NullOr(Schema.String),
45
+ profile: Schema.NullOr(Schema.String),
46
+ profile_requested: Schema.NullOr(Schema.String),
47
+ index_path: Schema.String,
48
+ index_exists: Schema.Boolean,
49
+ actors: Schema.Struct({ agent: Schema.NullOr(Schema.String) }),
50
+ types: Schema.Array(ContextType),
51
+ tags: Schema.Array(ContextTag)
52
+ });
53
+ /**
54
+ * Build the envelope from the merged config. `types`/`tags` sort by `name`
55
+ * with plain code-unit comparison, never locale-dependent — the same rule
56
+ * `render/sort.ts`'s K-17 comparator and
57
+ * `packages/profiles/src/SoftwareProject.ts:119`'s own sort use.
58
+ *
59
+ * @public
60
+ */
61
+ const contextEnvelope = (input) => ({
62
+ schema: 1,
63
+ project_root: input.projectRoot,
64
+ bundle_root: input.bundleRoot,
65
+ config_path: input.configPath,
66
+ profile: input.profile,
67
+ profile_requested: input.profileRequested,
68
+ index_path: input.indexPath,
69
+ index_exists: input.indexExists,
70
+ actors: { agent: input.config.actors?.agent ?? null },
71
+ types: Object.entries(input.config.types ?? {}).map(([name, decl]) => ({
72
+ name,
73
+ description: decl.description ?? null,
74
+ guidance: decl.guidance ?? null
75
+ })).toSorted((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
76
+ tags: Object.entries(input.config.tags ?? {}).map(([name, decl]) => ({
77
+ name,
78
+ description: decl.description ?? null
79
+ })).toSorted((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
80
+ });
81
+ /**
82
+ * The `human` format: a short header block, then one line per type and one
83
+ * per tag. Pure; the caller pipes each line through `Console.log`.
84
+ *
85
+ * The `profile:` line reads `profile: (none) (requested NAME, unknown)`
86
+ * when `profile` and `profile_requested` disagree over an actually-unknown
87
+ * profile — never for the `"none"` or no-config cases, where a `null`
88
+ * `profile` is expected, not an error.
89
+ *
90
+ * @public
91
+ */
92
+ const humanContext = (envelope) => [
93
+ `project root: ${envelope.project_root}`,
94
+ `bundle root: ${envelope.bundle_root}`,
95
+ `config: ${envelope.config_path ?? "(none)"}`,
96
+ envelope.profile === null && envelope.profile_requested !== null && envelope.profile_requested !== "none" ? `profile: (none) (requested ${envelope.profile_requested}, unknown)` : `profile: ${envelope.profile ?? "(none)"}`,
97
+ `index.md: ${envelope.index_path} (${envelope.index_exists ? "exists" : "missing"})`,
98
+ `agent: ${envelope.actors.agent ?? "(unset)"}`,
99
+ "",
100
+ "types:",
101
+ ...envelope.types.map((t) => ` ${t.name} ${t.description ?? ""}`),
102
+ "",
103
+ "tags:",
104
+ ...envelope.tags.map((t) => ` ${t.name} ${t.description ?? ""}`)
105
+ ];
106
+
107
+ //#endregion
108
+ export { ContextEnvelope, ContextTag, ContextType, contextEnvelope, humanContext };
package/render/exit.js ADDED
@@ -0,0 +1,53 @@
1
+ //#region src/render/exit.ts
2
+ /**
3
+ * One pass over the collected diagnostics. `core.conformance` entries are
4
+ * always severity `error` (D-33); the counts are still taken from `severity`
5
+ * so a future non-error conformance code cannot silently change the exit
6
+ * code.
7
+ *
8
+ * @public
9
+ */
10
+ const tally = (diagnostics) => {
11
+ let conformanceErrors = 0;
12
+ let lintErrors = 0;
13
+ let lintWarnings = 0;
14
+ let lintInfo = 0;
15
+ let profileErrors = 0;
16
+ for (const d of diagnostics) {
17
+ if (d.source === "core.conformance") {
18
+ if (d.severity === "error") conformanceErrors++;
19
+ continue;
20
+ }
21
+ if (d.source === "core.lint") {
22
+ if (d.severity === "error") lintErrors++;
23
+ else if (d.severity === "warning") lintWarnings++;
24
+ else lintInfo++;
25
+ continue;
26
+ }
27
+ if (d.severity === "error") profileErrors++;
28
+ }
29
+ return {
30
+ conformanceErrors,
31
+ lintErrors,
32
+ lintWarnings,
33
+ lintInfo,
34
+ profileErrors
35
+ };
36
+ };
37
+ /**
38
+ * K-7/K-8's two lowest tiers: `2` when any conformance error is present, else
39
+ * `1` when any lint OR profile error is present, else `0`. Warnings and info
40
+ * never move it. `130`, `64` and `3` are set elsewhere — by the runtime's
41
+ * interrupt branch, by the `ShowHelp` remap in `bin.ts`, and by the typed
42
+ * errors' own `[Runtime.errorExitCode]` — so this function returns only
43
+ * `0 | 1 | 2`.
44
+ *
45
+ * @public
46
+ */
47
+ const forDiagnostics = (diagnostics) => {
48
+ const t = tally(diagnostics);
49
+ return t.conformanceErrors > 0 ? 2 : t.lintErrors + t.profileErrors > 0 ? 1 : 0;
50
+ };
51
+
52
+ //#endregion
53
+ export { forDiagnostics, tally };
@@ -0,0 +1,68 @@
1
+ import { sort } from "./sort.js";
2
+
3
+ //#region src/render/human.ts
4
+ /** The ANSI escape character, built from its code point so the source never carries a raw control byte. */
5
+ const ESC = String.fromCharCode(27);
6
+ /** ANSI SGR codes for the severity word only (K-19); reset after, never applied elsewhere. */
7
+ const SEVERITY_COLOR = {
8
+ error: `${ESC}[31m`,
9
+ warning: `${ESC}[33m`,
10
+ info: `${ESC}[36m`
11
+ };
12
+ const RESET = `${ESC}[0m`;
13
+ const colorize = (severity, color) => color ? `${SEVERITY_COLOR[severity]}${severity}${RESET}` : severity;
14
+ /**
15
+ * K-16. With a range:
16
+ * `<file>:<range.line + 1>:<range.character + 1> <severity> <code> <message>`.
17
+ * Without one: `<file> <severity> <code> <message>`. `file: ""` renders as
18
+ * the literal `(bundle)`. Core's range is zero-based (D-32,
19
+ * `CORE/Diagnostic.ts:49-57`); the `+ 1`s here are the only place it becomes
20
+ * one-based. Colour, when `color` is `true`, wraps ONLY the severity word
21
+ * (K-19) — never the code, the path, or the message.
22
+ *
23
+ * @public
24
+ */
25
+ const line = (diagnostic, options) => {
26
+ const color = options?.color ?? false;
27
+ const file = diagnostic.file === "" ? "(bundle)" : diagnostic.file;
28
+ const severity = colorize(diagnostic.severity, color);
29
+ return `${diagnostic.range === void 0 ? file : `${file}:${diagnostic.range.line + 1}:${diagnostic.range.character + 1}`} ${severity} ${diagnostic.code} ${diagnostic.message}`;
30
+ };
31
+ /**
32
+ * `sort` then `line` over the whole set: the exact stdout body of
33
+ * `--format human`, one array element per stdout line.
34
+ *
35
+ * @public
36
+ */
37
+ const human = (diagnostics, options) => sort(diagnostics).map((d) => line(d, options));
38
+ /**
39
+ * K-20, verbatim and unpluralised —
40
+ * `<E> errors, <W> warnings, <I> info in <N> concepts (<root>)`. `root` is
41
+ * pre-rendered by the caller: relative to cwd when under it, absolute
42
+ * otherwise (K-51).
43
+ *
44
+ * @public
45
+ */
46
+ const summary = (counts, root) => `${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info in ${counts.concepts} concepts (${root})`;
47
+ /**
48
+ * The one K-51 display-path rule, shared by both `commands/validate.ts`'s
49
+ * `summary` root and `commands/init.ts`'s success line: `target` relative
50
+ * to `cwd` when it is under it, absolute otherwise. Three cases, in order:
51
+ *
52
+ * 1. `target === cwd` (`path.relative` returns `""`): the literal `.`.
53
+ * 2. The relative form starts with `..`, or is itself absolute (a target on
54
+ * a different root than `cwd`, where `Path.relative` can return an
55
+ * absolute path unchanged depending on the platform): `target`
56
+ * unchanged, absolute.
57
+ * 3. Otherwise: the relative form.
58
+ *
59
+ * @internal
60
+ */
61
+ const displayRoot = (cwd, target, path) => {
62
+ const relative = path.relative(cwd, target);
63
+ if (relative === "") return ".";
64
+ return relative.startsWith("..") || path.isAbsolute(relative) ? target : relative;
65
+ };
66
+
67
+ //#endregion
68
+ export { displayRoot, human, line, summary };
package/render/json.js ADDED
@@ -0,0 +1,126 @@
1
+ import { tally } from "./exit.js";
2
+ import { sort } from "./sort.js";
3
+ import { Schema } from "effect";
4
+ import { DiagnosticRange, DiagnosticSeverity } from "@okfit/core";
5
+
6
+ //#region src/render/json.ts
7
+ /** One entry of the `diagnostics` array (K-21). @public */
8
+ const JsonDiagnostic = Schema.Struct({
9
+ source: Schema.Literals([
10
+ "core.conformance",
11
+ "core.lint",
12
+ "profile"
13
+ ]),
14
+ file: Schema.String,
15
+ code: Schema.String,
16
+ severity: DiagnosticSeverity,
17
+ message: Schema.String,
18
+ range: Schema.optionalKey(DiagnosticRange)
19
+ });
20
+ /** @public */
21
+ const JsonSummary = Schema.Struct({
22
+ conformance_errors: Schema.Number,
23
+ lint_errors: Schema.Number,
24
+ lint_warnings: Schema.Number,
25
+ lint_info: Schema.Number,
26
+ profile_errors: Schema.Number,
27
+ concepts: Schema.Number
28
+ });
29
+ /**
30
+ * K-21's success envelope, snake_case. `exit_code` is `0 | 1 | 2` only: an
31
+ * infrastructure failure never produces this envelope, it produces the
32
+ * `JsonErrorEnvelope` (K-22).
33
+ *
34
+ * @public
35
+ */
36
+ const JsonEnvelope = Schema.Struct({
37
+ schema: Schema.Literal(1),
38
+ okfit_version: Schema.String,
39
+ okf_version: Schema.String,
40
+ root: Schema.String,
41
+ profile: Schema.NullOr(Schema.String),
42
+ exit_code: Schema.Literals([
43
+ 0,
44
+ 1,
45
+ 2
46
+ ]),
47
+ summary: JsonSummary,
48
+ diagnostics: Schema.Array(JsonDiagnostic)
49
+ });
50
+ /** K-22's envelope: the ONLY thing stdout carries under `--format json` on an exit-3 failure. @public */
51
+ const JsonErrorEnvelope = Schema.Struct({
52
+ schema: Schema.Literal(1),
53
+ okfit_version: Schema.String,
54
+ exit_code: Schema.Literal(3),
55
+ error: Schema.Struct({
56
+ tag: Schema.String,
57
+ message: Schema.String
58
+ })
59
+ });
60
+ const toJsonDiagnostic = (d) => ({
61
+ source: d.source,
62
+ file: d.file,
63
+ code: d.code,
64
+ severity: d.severity,
65
+ message: d.message,
66
+ ...d.range === void 0 ? {} : { range: d.range }
67
+ });
68
+ /**
69
+ * Build the success envelope. `diagnostics` is sorted here with the same
70
+ * `sort` the human renderer uses (K-21's "diagnostics are in the K-17
71
+ * order"); `range` passes through as core computed it, zero-based.
72
+ *
73
+ * The value returned is in `Type` form (its `range`s are `DiagnosticRange`
74
+ * instances). What stdout carries is `Schema.encodeSync(JsonEnvelope)` of it,
75
+ * `JSON.stringify`-ed — one document, no trailing text.
76
+ *
77
+ * @public
78
+ */
79
+ const json = (input) => {
80
+ const t = tally(input.diagnostics);
81
+ return {
82
+ schema: 1,
83
+ okfit_version: input.okfitVersion,
84
+ okf_version: input.okfVersion,
85
+ root: input.root,
86
+ profile: input.profile,
87
+ exit_code: input.exitCode,
88
+ summary: {
89
+ conformance_errors: t.conformanceErrors,
90
+ lint_errors: t.lintErrors,
91
+ lint_warnings: t.lintWarnings,
92
+ lint_info: t.lintInfo,
93
+ profile_errors: t.profileErrors,
94
+ concepts: input.concepts
95
+ },
96
+ diagnostics: sort(input.diagnostics).map(toJsonDiagnostic)
97
+ };
98
+ };
99
+ /** The error's `_tag` when it has one, else its constructor name; `"UnknownError"` for a non-object value. */
100
+ const tagOf = (error) => {
101
+ if (typeof error !== "object" || error === null) return "UnknownError";
102
+ const tag = error._tag;
103
+ if (typeof tag === "string") return tag;
104
+ return error.constructor?.name ?? "UnknownError";
105
+ };
106
+ /** The error's `message` when it has one as a string, else `String(error)`. */
107
+ const messageOf = (error) => {
108
+ if (typeof error === "object" && error !== null && "message" in error) {
109
+ const message = error.message;
110
+ if (typeof message === "string") return message;
111
+ }
112
+ return String(error);
113
+ };
114
+ /** K-22. `tag` is the error's `_tag` when it has one, else its constructor name. @public */
115
+ const jsonError = (error, okfitVersion) => ({
116
+ schema: 1,
117
+ okfit_version: okfitVersion,
118
+ exit_code: 3,
119
+ error: {
120
+ tag: tagOf(error),
121
+ message: messageOf(error)
122
+ }
123
+ });
124
+
125
+ //#endregion
126
+ export { JsonDiagnostic, JsonEnvelope, JsonErrorEnvelope, JsonSummary, json, jsonError };
package/render/sort.js ADDED
@@ -0,0 +1,46 @@
1
+ //#region src/render/sort.ts
2
+ const toRendered = (source) => (entry) => ({
3
+ source,
4
+ file: entry.file,
5
+ code: entry.code,
6
+ severity: entry.severity,
7
+ message: entry.message,
8
+ ...entry.range === void 0 ? {} : { range: entry.range }
9
+ });
10
+ /**
11
+ * Tag core's two arrays and profiles' one, in producer order. Not named
12
+ * `merge`: `OkfitConfig.merge` already owns that word in this codebase.
13
+ *
14
+ * @public
15
+ */
16
+ const collect = (conformance, lint, profile) => [
17
+ ...conformance.map(toRendered("core.conformance")),
18
+ ...lint.map(toRendered("core.lint")),
19
+ ...profile.map(toRendered("profile"))
20
+ ];
21
+ /** Range-less entries sort before ranged ones within the same file; ranged entries by `offset`. */
22
+ const compareRange = (a, b) => {
23
+ if (a.range === void 0 && b.range === void 0) return 0;
24
+ if (a.range === void 0) return -1;
25
+ if (b.range === void 0) return 1;
26
+ return a.range.offset - b.range.offset;
27
+ };
28
+ /**
29
+ * K-17: `file` ascending (so `""` — the bundle-level finding — leads), then
30
+ * range-less before ranged within a file, then `range.offset` ascending, then
31
+ * `code`. Plain code-unit comparison, never locale-dependent (the same rule
32
+ * profiles' own `check` sorts by, `PROFILES/SoftwareProject.ts:119`). Stable:
33
+ * implemented with `toSorted`, which is specified as stable.
34
+ *
35
+ * @public
36
+ */
37
+ const sort = (diagnostics) => diagnostics.toSorted((a, b) => {
38
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
39
+ const byRange = compareRange(a, b);
40
+ if (byRange !== 0) return byRange;
41
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
42
+ return 0;
43
+ });
44
+
45
+ //#endregion
46
+ export { collect, sort };