@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/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
|
-
|
|
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 };
|
package/init/scaffold.js
ADDED
|
@@ -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 };
|
package/internal/exit.js
ADDED
|
@@ -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 };
|
package/internal/tty.js
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okfit/cli",
|
|
3
|
-
"version": "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.
|
|
41
|
-
"@
|
|
42
|
-
"@
|
|
43
|
-
"
|
|
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 };
|
package/render/human.js
ADDED
|
@@ -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 };
|