avouch 0.1.22
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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +356 -0
- package/SPEC.md +379 -0
- package/dist/cli/avouch.d.ts +2 -0
- package/dist/cli/avouch.js +68 -0
- package/dist/cli/check.d.ts +86 -0
- package/dist/cli/check.js +333 -0
- package/dist/cli/facts.d.ts +3 -0
- package/dist/cli/facts.js +157 -0
- package/dist/cli/gate.d.ts +3 -0
- package/dist/cli/gate.js +149 -0
- package/dist/cli/init.d.ts +21 -0
- package/dist/cli/init.js +282 -0
- package/dist/cli/jsonl.d.ts +5 -0
- package/dist/cli/jsonl.js +20 -0
- package/dist/cli/query.d.ts +2 -0
- package/dist/cli/query.js +138 -0
- package/dist/cli/reconcile.d.ts +9 -0
- package/dist/cli/reconcile.js +131 -0
- package/dist/src/checker.d.ts +3 -0
- package/dist/src/checker.js +1136 -0
- package/dist/src/contract.d.ts +135 -0
- package/dist/src/contract.js +3 -0
- package/dist/src/coverage.d.ts +65 -0
- package/dist/src/coverage.js +237 -0
- package/dist/src/evaluate.d.ts +66 -0
- package/dist/src/evaluate.js +535 -0
- package/dist/src/expr.d.ts +209 -0
- package/dist/src/expr.js +497 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +11 -0
- package/dist/src/ledger.d.ts +96 -0
- package/dist/src/ledger.js +241 -0
- package/dist/src/markdown.d.ts +66 -0
- package/dist/src/markdown.js +701 -0
- package/dist/src/patch.d.ts +6 -0
- package/dist/src/patch.js +37 -0
- package/dist/src/query.d.ts +333 -0
- package/dist/src/query.js +739 -0
- package/dist/src/reconcile.d.ts +61 -0
- package/dist/src/reconcile.js +151 -0
- package/dist/src/stage1-check.d.ts +31 -0
- package/dist/src/stage1-check.js +329 -0
- package/dist/src/touched.d.ts +1 -0
- package/dist/src/touched.js +9 -0
- package/dist/src/unknown.d.ts +10 -0
- package/dist/src/unknown.js +173 -0
- package/docs/adapter-guide.md +187 -0
- package/docs/markdown-convention.md +635 -0
- package/ontology.schema.json +1757 -0
- package/package.json +57 -0
- package/templates/agents-block.md +12 -0
- package/vocab.ttl +524 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type ErrorObject } from "ajv/dist/2020.js";
|
|
2
|
+
import type { Report } from "../src/contract.js";
|
|
3
|
+
import type { QueryStamp } from "../src/query.js";
|
|
4
|
+
import { type MarkdownConfig, type MarkdownDoc } from "../src/markdown.js";
|
|
5
|
+
/** The project configuration file, at the git top level of the project. */
|
|
6
|
+
export declare const CONFIG_FILE = "avouch.config.json";
|
|
7
|
+
export declare const ADAPTER_HINT = "next step: https://github.com/githonllc/avouch/blob/main/docs/adapter-guide.md#for-ai-agents (Markdown documents need only {\"adapter\": {\"markdown\": {\"docs\": [...]}}} in avouch.config.json; other documents need an adapter module)";
|
|
8
|
+
export declare const SETUP_HINT = "to set up a project, run `npx -y avouch init`, then /avouch:scan in Claude Code (it drafts avouch.config.json and a first ontology)";
|
|
9
|
+
/**
|
|
10
|
+
* An adapter, ready to run. `label` is the value of `check.adapter` in a query or gate stamp. A Markdown configuration reads
|
|
11
|
+
* its documents relative to `dir`; `where` is the prefix of an error about it.
|
|
12
|
+
*/
|
|
13
|
+
export type Adapter = {
|
|
14
|
+
kind: "markdown";
|
|
15
|
+
config: MarkdownConfig;
|
|
16
|
+
dir: string;
|
|
17
|
+
where: string;
|
|
18
|
+
label: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "module";
|
|
21
|
+
path: string;
|
|
22
|
+
label: string;
|
|
23
|
+
};
|
|
24
|
+
/** The adapter named by `--adapter`: a `.json` file is the configuration of the built-in Markdown adapter, anything else a module. */
|
|
25
|
+
export declare function adapterFromArgument(arg: string): Adapter;
|
|
26
|
+
export declare function gitTop(cwd: string): string | null;
|
|
27
|
+
export interface ProjectOptions {
|
|
28
|
+
/** -f */
|
|
29
|
+
file?: string;
|
|
30
|
+
/** --config */
|
|
31
|
+
config?: string;
|
|
32
|
+
/** --adapter */
|
|
33
|
+
adapter?: string;
|
|
34
|
+
/** true when the command must run the check */
|
|
35
|
+
check: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The ontology file of a command and, when it runs the check, the adapter.
|
|
39
|
+
* Ontology: -f, else `ontology` of the project configuration, else avouch/avouch.ontology.yaml at the git top level.
|
|
40
|
+
* Adapter: --adapter, else `adapter` of the project configuration.
|
|
41
|
+
* The project configuration is --config, else avouch.config.json at the git top level of the directory of -f (with -f)
|
|
42
|
+
* or of the working directory. It is read only when the command has no -f, or must run the check without --adapter.
|
|
43
|
+
* A named -f or --config that is not a file is an error, whether or not the configuration is read (a stat, nothing more).
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveProject(o: ProjectOptions): {
|
|
46
|
+
file: string;
|
|
47
|
+
adapter: Adapter | null;
|
|
48
|
+
};
|
|
49
|
+
/** The inputs of `avouch gate docs`: the ledger file, the ontology file and the documents (paths relative to the configuration directory, with `/`). */
|
|
50
|
+
export interface LedgerProject {
|
|
51
|
+
config: string;
|
|
52
|
+
dir: string;
|
|
53
|
+
ledger: string;
|
|
54
|
+
ontology: string;
|
|
55
|
+
docs: string[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The project of `avouch gate docs`: --config, else avouch.config.json at the git top level of the working directory.
|
|
59
|
+
* The ledger file is `ledger.file` (default avouch.ledger.jsonl), relative to the configuration file. The documents are
|
|
60
|
+
* `ledger.docs`, else the docs of the Markdown adapter; a module adapter has no docs, so it needs `ledger.docs`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveLedger(configOption: string | undefined): LedgerProject;
|
|
63
|
+
export declare function readDocs(a: Extract<Adapter, {
|
|
64
|
+
kind: "markdown";
|
|
65
|
+
}>): MarkdownDoc[];
|
|
66
|
+
/**
|
|
67
|
+
* The Markdown configuration of `avouch facts`: --adapter, else the `adapter` of the project configuration (--config, else
|
|
68
|
+
* avouch.config.json at the git top level of the working directory). Reads no ontology. A module adapter is an error.
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveMarkdownAdapter(o: {
|
|
71
|
+
config?: string;
|
|
72
|
+
adapter?: string;
|
|
73
|
+
}): Extract<Adapter, {
|
|
74
|
+
kind: "markdown";
|
|
75
|
+
}>;
|
|
76
|
+
export declare function runCheck(ontologyFile: string, adapter: Adapter): Promise<{
|
|
77
|
+
schemaErrors: ErrorObject[];
|
|
78
|
+
report: Report;
|
|
79
|
+
}>;
|
|
80
|
+
/** The `check` of a query or gate stamp: the check run with the adapter, or `not_run` when there is no adapter. */
|
|
81
|
+
export declare function checkStamp(ontologyFile: string, adapter: Adapter | null): Promise<QueryStamp["check"]>;
|
|
82
|
+
/** The project of a query or gate command: the check runs with --check, or with --adapter. */
|
|
83
|
+
export declare function resolveQueryProject(opts: Map<string, string | true>): {
|
|
84
|
+
file: string;
|
|
85
|
+
adapter: Adapter | null;
|
|
86
|
+
};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import { check } from "../src/checker.js";
|
|
8
|
+
import { isObj } from "../src/expr.js";
|
|
9
|
+
import { markdownFacts, parseMarkdownConfig, validateMarkdownConfig } from "../src/markdown.js";
|
|
10
|
+
/** The project configuration file, at the git top level of the project. */
|
|
11
|
+
export const CONFIG_FILE = "avouch.config.json";
|
|
12
|
+
// The next step when a repository has no Avouch project yet (no configuration and no default ontology).
|
|
13
|
+
// The next step when a command needs an adapter and none is set: the guide section for agents.
|
|
14
|
+
export const ADAPTER_HINT = "next step: https://github.com/githonllc/avouch/blob/main/docs/adapter-guide.md#for-ai-agents (Markdown documents need only {\"adapter\": {\"markdown\": {\"docs\": [...]}}} in avouch.config.json; other documents need an adapter module)";
|
|
15
|
+
export const SETUP_HINT = `to set up a project, run \`npx -y avouch init\`, then /avouch:scan in Claude Code (it drafts ${CONFIG_FILE} and a first ontology)`;
|
|
16
|
+
/** The adapter named by `--adapter`: a `.json` file is the configuration of the built-in Markdown adapter, anything else a module. */
|
|
17
|
+
export function adapterFromArgument(arg) {
|
|
18
|
+
if (!arg.endsWith(".json"))
|
|
19
|
+
return { kind: "module", path: resolve(arg), label: arg };
|
|
20
|
+
const path = resolve(arg);
|
|
21
|
+
return { kind: "markdown", config: parseMarkdownConfig(readFileSync(path, "utf8"), arg), dir: dirname(path), where: arg, label: arg };
|
|
22
|
+
}
|
|
23
|
+
// Reads and validates an avouch.config.json (strict: an unknown key is an error). Throws `<file>: <json path>: <problem>`.
|
|
24
|
+
// `label` names the file in the stamp: the --config argument as given, or CONFIG_FILE when the file was found at the git top.
|
|
25
|
+
function loadProjectConfig(file, label) {
|
|
26
|
+
const fail = (path, problem) => {
|
|
27
|
+
throw new Error(`${file}: ${path}: ${problem}`);
|
|
28
|
+
};
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(readFileSync(resolve(file), "utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return fail("(root)", `not valid JSON: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
if (!isObj(raw))
|
|
37
|
+
return fail("(root)", "must be an object");
|
|
38
|
+
for (const k of Object.keys(raw))
|
|
39
|
+
if (k !== "ontology" && k !== "adapter" && k !== "ledger")
|
|
40
|
+
fail(k, "unknown key");
|
|
41
|
+
// `ontology` is optional here; a command that needs it fails in resolveProject or resolveLedger
|
|
42
|
+
if (Object.hasOwn(raw, "ontology") && (typeof raw.ontology !== "string" || raw.ontology === ""))
|
|
43
|
+
fail("ontology", "must be a non-empty string");
|
|
44
|
+
if (!Object.hasOwn(raw, "adapter"))
|
|
45
|
+
fail("adapter", "missing");
|
|
46
|
+
const a = raw.adapter;
|
|
47
|
+
const dir = dirname(resolve(file));
|
|
48
|
+
const ontology = typeof raw.ontology === "string" ? resolve(dir, raw.ontology) : null;
|
|
49
|
+
let ledger = null;
|
|
50
|
+
if (Object.hasOwn(raw, "ledger")) {
|
|
51
|
+
const l = raw.ledger;
|
|
52
|
+
if (!isObj(l))
|
|
53
|
+
fail("ledger", "must be an object");
|
|
54
|
+
for (const k of Object.keys(l))
|
|
55
|
+
if (k !== "file" && k !== "docs")
|
|
56
|
+
fail(`ledger.${k}`, "unknown key");
|
|
57
|
+
ledger = {};
|
|
58
|
+
if (Object.hasOwn(l, "file")) {
|
|
59
|
+
if (typeof l.file !== "string" || l.file === "")
|
|
60
|
+
fail("ledger.file", "must be a non-empty string");
|
|
61
|
+
ledger.file = l.file;
|
|
62
|
+
}
|
|
63
|
+
if (Object.hasOwn(l, "docs")) {
|
|
64
|
+
if (!Array.isArray(l.docs) || l.docs.length === 0)
|
|
65
|
+
fail("ledger.docs", "must be a non-empty array of strings");
|
|
66
|
+
const docs = l.docs;
|
|
67
|
+
docs.forEach((d, i) => {
|
|
68
|
+
if (typeof d !== "string" || d === "")
|
|
69
|
+
fail(`ledger.docs[${i}]`, "must be a non-empty string");
|
|
70
|
+
const j = docs.indexOf(d);
|
|
71
|
+
if (j !== i)
|
|
72
|
+
fail(`ledger.docs[${i}]`, `duplicate of ledger.docs[${j}]`);
|
|
73
|
+
});
|
|
74
|
+
ledger.docs = [...docs];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (typeof a === "string") {
|
|
78
|
+
if (a === "")
|
|
79
|
+
fail("adapter", "must be a non-empty string");
|
|
80
|
+
return { file, ontology, adapter: { written: a, path: resolve(dir, a) }, ledger };
|
|
81
|
+
}
|
|
82
|
+
if (!isObj(a))
|
|
83
|
+
return fail("adapter", "must be a string or an object");
|
|
84
|
+
for (const k of Object.keys(a))
|
|
85
|
+
if (k !== "markdown")
|
|
86
|
+
fail(`adapter.${k}`, "unknown key");
|
|
87
|
+
if (!Object.hasOwn(a, "markdown"))
|
|
88
|
+
fail("adapter.markdown", "missing");
|
|
89
|
+
if (!isObj(a.markdown))
|
|
90
|
+
fail("adapter.markdown", "must be an object");
|
|
91
|
+
const where = `${file}: adapter.markdown`;
|
|
92
|
+
return { file, ontology, adapter: { kind: "markdown", config: validateMarkdownConfig(a.markdown, where), dir, where, label: `${label}#adapter` }, ledger };
|
|
93
|
+
}
|
|
94
|
+
// The git top level of a directory, or null when it is not in a git repository (or git is not available).
|
|
95
|
+
export function gitTop(cwd) {
|
|
96
|
+
const r = spawnSync("git", ["--no-optional-locks", "rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
|
|
97
|
+
return r.error !== undefined || r.status !== 0 ? null : r.stdout.trim();
|
|
98
|
+
}
|
|
99
|
+
const isFile = (path) => {
|
|
100
|
+
try {
|
|
101
|
+
return statSync(resolve(path)).isFile();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const real = (path) => {
|
|
108
|
+
try {
|
|
109
|
+
return realpathSync(resolve(path));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return resolve(path);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
// The project configuration: `configArg` (--config), else avouch.config.json at the git top level of `startDir` (a directory
|
|
116
|
+
// that is not in a git repository has none). `top` is the git top level when it was looked up.
|
|
117
|
+
function findProjectConfig(configArg, startDir) {
|
|
118
|
+
if (configArg !== undefined)
|
|
119
|
+
return { config: loadProjectConfig(configArg, configArg), top: null };
|
|
120
|
+
const top = gitTop(startDir);
|
|
121
|
+
const file = top !== null && existsSync(join(top, CONFIG_FILE)) ? join(top, CONFIG_FILE) : null;
|
|
122
|
+
return { config: file !== null ? loadProjectConfig(file, CONFIG_FILE) : null, top };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The ontology file of a command and, when it runs the check, the adapter.
|
|
126
|
+
* Ontology: -f, else `ontology` of the project configuration, else avouch/avouch.ontology.yaml at the git top level.
|
|
127
|
+
* Adapter: --adapter, else `adapter` of the project configuration.
|
|
128
|
+
* The project configuration is --config, else avouch.config.json at the git top level of the directory of -f (with -f)
|
|
129
|
+
* or of the working directory. It is read only when the command has no -f, or must run the check without --adapter.
|
|
130
|
+
* A named -f or --config that is not a file is an error, whether or not the configuration is read (a stat, nothing more).
|
|
131
|
+
*/
|
|
132
|
+
export function resolveProject(o) {
|
|
133
|
+
if (o.file !== undefined && !isFile(o.file))
|
|
134
|
+
throw new Error(`-f ${o.file}: file not found`);
|
|
135
|
+
if (o.config !== undefined && !isFile(o.config))
|
|
136
|
+
throw new Error(`--config ${o.config}: file not found`);
|
|
137
|
+
const needAdapterFromConfig = o.check && o.adapter === undefined;
|
|
138
|
+
const readConfig = o.file === undefined || needAdapterFromConfig;
|
|
139
|
+
let top = null;
|
|
140
|
+
let config = null;
|
|
141
|
+
if (readConfig)
|
|
142
|
+
({ config, top } = findProjectConfig(o.config, o.file !== undefined ? dirname(resolve(o.file)) : process.cwd()));
|
|
143
|
+
let file;
|
|
144
|
+
if (o.file !== undefined)
|
|
145
|
+
file = o.file;
|
|
146
|
+
else if (config !== null) {
|
|
147
|
+
if (config.ontology === null)
|
|
148
|
+
throw new Error(`${config.file}: ontology: missing`);
|
|
149
|
+
if (!isFile(config.ontology))
|
|
150
|
+
throw new Error(`${config.file}: ontology: file not found`);
|
|
151
|
+
file = config.ontology;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// no -f, no --config (a --config always gives a configuration): the git top level is the only place left to look
|
|
155
|
+
if (top === null)
|
|
156
|
+
throw new Error("no -f and no --config, and the working directory is not in a git repository (or git is not available)");
|
|
157
|
+
const dflt = join(top, "avouch", "avouch.ontology.yaml");
|
|
158
|
+
if (!existsSync(dflt))
|
|
159
|
+
throw new Error(`no -f, no ${CONFIG_FILE} at ${top}, and ${dflt} does not exist; ${SETUP_HINT}`);
|
|
160
|
+
file = dflt;
|
|
161
|
+
}
|
|
162
|
+
if (!o.check)
|
|
163
|
+
return { file, adapter: null };
|
|
164
|
+
if (o.adapter !== undefined)
|
|
165
|
+
return { file, adapter: adapterFromArgument(o.adapter) };
|
|
166
|
+
if (config === null) {
|
|
167
|
+
const dir = dirname(resolve(file));
|
|
168
|
+
const where = top !== null ? join(top, CONFIG_FILE) : CONFIG_FILE;
|
|
169
|
+
const rel = top !== null ? relative(top, join(dir, "avouch.json")) : join(dir, "avouch.json");
|
|
170
|
+
const old = existsSync(join(dir, "avouch.json")) ? `; ${join(dir, "avouch.json")} is no longer read: set "adapter": "${rel}" in ${CONFIG_FILE}, or pass --adapter ${join(dir, "avouch.json")}` : "";
|
|
171
|
+
throw new Error(`no adapter: pass --adapter, or set adapter in ${where}${old}; ${ADAPTER_HINT}`);
|
|
172
|
+
}
|
|
173
|
+
// a configuration without `ontology` has nothing to differ from -f
|
|
174
|
+
if (o.file !== undefined && config.ontology !== null && real(o.file) !== real(config.ontology))
|
|
175
|
+
throw new Error(`-f ${o.file} is not the ontology in ${config.file} (${config.ontology}): pass --adapter`);
|
|
176
|
+
if (!("written" in config.adapter))
|
|
177
|
+
return { file, adapter: config.adapter };
|
|
178
|
+
// the stamp holds the string as written, as --adapter stamps its argument as given
|
|
179
|
+
if (!isFile(config.adapter.path))
|
|
180
|
+
throw new Error(`${config.file}: adapter: file not found`);
|
|
181
|
+
return { file, adapter: { ...adapterFromArgument(config.adapter.path), label: config.adapter.written } };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The project of `avouch gate docs`: --config, else avouch.config.json at the git top level of the working directory.
|
|
185
|
+
* The ledger file is `ledger.file` (default avouch.ledger.jsonl), relative to the configuration file. The documents are
|
|
186
|
+
* `ledger.docs`, else the docs of the Markdown adapter; a module adapter has no docs, so it needs `ledger.docs`.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveLedger(configOption) {
|
|
189
|
+
let configFile;
|
|
190
|
+
let label = CONFIG_FILE;
|
|
191
|
+
if (configOption !== undefined) {
|
|
192
|
+
if (!isFile(configOption))
|
|
193
|
+
throw new Error(`--config ${configOption}: file not found`);
|
|
194
|
+
configFile = label = configOption;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const top = gitTop(process.cwd());
|
|
198
|
+
if (top === null)
|
|
199
|
+
throw new Error("no --config, and the working directory is not in a git repository (or git is not available)");
|
|
200
|
+
configFile = join(top, CONFIG_FILE);
|
|
201
|
+
if (!existsSync(configFile))
|
|
202
|
+
throw new Error(`no --config, and no ${CONFIG_FILE} at ${top}; ${SETUP_HINT}`);
|
|
203
|
+
}
|
|
204
|
+
const c = loadProjectConfig(configFile, label);
|
|
205
|
+
if (c.ontology === null)
|
|
206
|
+
throw new Error(`${configFile}: ontology: missing`);
|
|
207
|
+
const ontology = c.ontology;
|
|
208
|
+
const dir = dirname(resolve(configFile));
|
|
209
|
+
let base = dir;
|
|
210
|
+
let docs;
|
|
211
|
+
if (c.ledger?.docs !== undefined)
|
|
212
|
+
docs = c.ledger.docs;
|
|
213
|
+
else if (!("written" in c.adapter))
|
|
214
|
+
docs = c.adapter.kind === "markdown" ? c.adapter.config.docs : [];
|
|
215
|
+
else if (!c.adapter.written.endsWith(".json"))
|
|
216
|
+
throw new Error(`${configFile}: ledger.docs: missing (required with a module adapter)`);
|
|
217
|
+
else {
|
|
218
|
+
if (!isFile(c.adapter.path))
|
|
219
|
+
throw new Error(`${configFile}: adapter: file not found`);
|
|
220
|
+
docs = parseMarkdownConfig(readFileSync(c.adapter.path, "utf8"), c.adapter.written).docs;
|
|
221
|
+
base = dirname(c.adapter.path);
|
|
222
|
+
}
|
|
223
|
+
const rel = docs.map((d) => relative(dir, resolve(base, d)).split(sep).join("/"));
|
|
224
|
+
// file identity (device and inode), as in readDocs: a case variant on a case-insensitive file system or a link is the same document
|
|
225
|
+
const ids = rel.map((d) => {
|
|
226
|
+
try {
|
|
227
|
+
const st = statSync(resolve(dir, d));
|
|
228
|
+
return `${st.dev}:${st.ino}`;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
throw new Error(`${configFile}: ledger: ${d}: ${error.message}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
ids.forEach((id, j) => {
|
|
235
|
+
if (ids.indexOf(id) !== j)
|
|
236
|
+
throw new Error(`${configFile}: ledger: document ${rel[j]} is named twice`);
|
|
237
|
+
});
|
|
238
|
+
if (!isFile(ontology))
|
|
239
|
+
throw new Error(`${configFile}: ontology: file not found`);
|
|
240
|
+
return { config: configFile, dir, ledger: resolve(dir, c.ledger?.file ?? "avouch.ledger.jsonl"), ontology, docs: rel };
|
|
241
|
+
}
|
|
242
|
+
// Reads the documents of a Markdown configuration (paths relative to the configuration file).
|
|
243
|
+
export function readDocs(a) {
|
|
244
|
+
const cfg = a.config;
|
|
245
|
+
const docError = (i, error) => new Error(`${a.where}: docs[${i}]: ${error.message}`);
|
|
246
|
+
const paths = cfg.docs.map((doc) => resolve(a.dir, doc));
|
|
247
|
+
// file identity (device and inode), so a case variant on a case-insensitive file system or a link is a duplicate too
|
|
248
|
+
const ids = paths.map((path, i) => {
|
|
249
|
+
try {
|
|
250
|
+
const st = statSync(path);
|
|
251
|
+
return `${st.dev}:${st.ino}`;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
throw docError(i, error);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
ids.forEach((id, j) => {
|
|
258
|
+
const i = ids.indexOf(id);
|
|
259
|
+
if (i !== j)
|
|
260
|
+
throw new Error(`${a.where}: docs[${j}]: duplicate of docs[${i}]`);
|
|
261
|
+
});
|
|
262
|
+
return paths.map((path, i) => {
|
|
263
|
+
try {
|
|
264
|
+
return { path: cfg.docs[i], text: readFileSync(path, "utf8") };
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
throw docError(i, error);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// Built-in Markdown adapter: reads its documents.
|
|
272
|
+
const markdownAdapter = (a) => markdownFacts(readDocs(a), a.config);
|
|
273
|
+
/**
|
|
274
|
+
* The Markdown configuration of `avouch facts`: --adapter, else the `adapter` of the project configuration (--config, else
|
|
275
|
+
* avouch.config.json at the git top level of the working directory). Reads no ontology. A module adapter is an error.
|
|
276
|
+
*/
|
|
277
|
+
export function resolveMarkdownAdapter(o) {
|
|
278
|
+
if (o.config !== undefined && !isFile(o.config))
|
|
279
|
+
throw new Error(`--config ${o.config}: file not found`);
|
|
280
|
+
let adapter;
|
|
281
|
+
if (o.adapter !== undefined)
|
|
282
|
+
adapter = adapterFromArgument(o.adapter);
|
|
283
|
+
else {
|
|
284
|
+
const { config, top } = findProjectConfig(o.config, process.cwd());
|
|
285
|
+
if (config === null)
|
|
286
|
+
throw new Error(`facts: no adapter: pass --adapter, or set adapter in ${top !== null ? join(top, CONFIG_FILE) : CONFIG_FILE}; ${ADAPTER_HINT}`);
|
|
287
|
+
if ("written" in config.adapter) {
|
|
288
|
+
if (!isFile(config.adapter.path))
|
|
289
|
+
throw new Error(`${config.file}: adapter: file not found`);
|
|
290
|
+
adapter = { ...adapterFromArgument(config.adapter.path), label: config.adapter.written };
|
|
291
|
+
}
|
|
292
|
+
else
|
|
293
|
+
adapter = config.adapter;
|
|
294
|
+
}
|
|
295
|
+
if (adapter.kind !== "markdown")
|
|
296
|
+
throw new Error("facts: the adapter must be a Markdown configuration");
|
|
297
|
+
return adapter;
|
|
298
|
+
}
|
|
299
|
+
// Runs the JSON Schema validator and the checker on one ontology file with one adapter: a module that exports `facts`, or the
|
|
300
|
+
// configuration of the built-in Markdown adapter. Shared by `avouch check` and the `--check` of `query` and `gate verify`,
|
|
301
|
+
// so all report the same counts.
|
|
302
|
+
export async function runCheck(ontologyFile, adapter) {
|
|
303
|
+
const ontologyPath = resolve(ontologyFile);
|
|
304
|
+
const schema = JSON.parse(readFileSync(new URL("../../ontology.schema.json", import.meta.url), "utf8"));
|
|
305
|
+
const ontology = parse(readFileSync(ontologyPath, "utf8"));
|
|
306
|
+
let facts;
|
|
307
|
+
if (adapter.kind === "markdown")
|
|
308
|
+
facts = markdownAdapter(adapter);
|
|
309
|
+
else {
|
|
310
|
+
const mod = await import(pathToFileURL(adapter.path).href);
|
|
311
|
+
if (typeof mod.facts !== "function")
|
|
312
|
+
throw new Error("adapter must export a named facts function");
|
|
313
|
+
facts = await mod.facts(ontologyPath);
|
|
314
|
+
}
|
|
315
|
+
const validate = new Ajv2020({ allErrors: true, allowUnionTypes: true }).compile(schema);
|
|
316
|
+
validate(ontology);
|
|
317
|
+
const schemaErrors = validate.errors ?? [];
|
|
318
|
+
const report = check(ontology, facts);
|
|
319
|
+
return { schemaErrors, report };
|
|
320
|
+
}
|
|
321
|
+
/** The `check` of a query or gate stamp: the check run with the adapter, or `not_run` when there is no adapter. */
|
|
322
|
+
export async function checkStamp(ontologyFile, adapter) {
|
|
323
|
+
if (adapter === null)
|
|
324
|
+
return { status: "not_run" };
|
|
325
|
+
const { schemaErrors, report } = await runCheck(ontologyFile, adapter);
|
|
326
|
+
const pass = schemaErrors.length === 0 && report.violations.length === 0;
|
|
327
|
+
return { status: pass ? "pass" : "fail", adapter: adapter.label, schemaErrors: schemaErrors.length, violations: report.violations.length, waived: report.waived.length };
|
|
328
|
+
}
|
|
329
|
+
/** The project of a query or gate command: the check runs with --check, or with --adapter. */
|
|
330
|
+
export function resolveQueryProject(opts) {
|
|
331
|
+
const adapter = opts.get("--adapter");
|
|
332
|
+
return resolveProject({ file: opts.get("-f"), config: opts.get("--config"), adapter, check: opts.get("--check") === true || adapter !== undefined });
|
|
333
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const FACTS_USAGE = "usage: avouch facts [--config <avouch.config.json>] [--adapter <config.json>] [--json] [--inventory <file.json>]";
|
|
2
|
+
/** `avouch facts`: parses the documents with the built-in Markdown adapter, with no ontology, and reports what it read. */
|
|
3
|
+
export declare function runFacts(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { isObj } from "../src/expr.js";
|
|
4
|
+
import { markdownFacts, markdownTextBlocks } from "../src/markdown.js";
|
|
5
|
+
import { readDocs, resolveMarkdownAdapter } from "./check.js";
|
|
6
|
+
import { expectPositionals, parseOptions, wantsHelp } from "./reconcile.js";
|
|
7
|
+
export const FACTS_USAGE = "usage: avouch facts [--config <avouch.config.json>] [--adapter <config.json>] [--json] [--inventory <file.json>]";
|
|
8
|
+
const NAMES = ["storeCatalog", "eventCatalog", "contextMembers", "permissions", "scenarios", "stateMachines", "transitionTriggers"];
|
|
9
|
+
const sorted = (xs) => [...xs].sort();
|
|
10
|
+
const byKey = (entries) => Object.fromEntries([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
11
|
+
/** A configured fact: the `FACT` lines and the JSON value (Sets as sorted arrays, Maps as objects with sorted keys), or null when it is not configured. */
|
|
12
|
+
function describeFact(name, facts, config) {
|
|
13
|
+
const line = (anchor, text, object = "") => `FACT ${name}${object} ${anchor}: ${text}`;
|
|
14
|
+
const anchor = name === "stateMachines" ? "" : config.facts[name];
|
|
15
|
+
if (name === "stateMachines" ? facts.stateMachines === undefined : anchor === undefined)
|
|
16
|
+
return null;
|
|
17
|
+
switch (name) {
|
|
18
|
+
case "storeCatalog":
|
|
19
|
+
case "eventCatalog": {
|
|
20
|
+
const x = facts[name].value;
|
|
21
|
+
return { lines: [line(anchor, `${x.size} names`)], json: sorted(x) };
|
|
22
|
+
}
|
|
23
|
+
case "contextMembers": {
|
|
24
|
+
const x = facts.contextMembers;
|
|
25
|
+
return { lines: [line(anchor, `${x.size} contexts`)], json: byKey([...x].map(([c, m]) => [c, sorted(m.value)])) };
|
|
26
|
+
}
|
|
27
|
+
case "permissions": {
|
|
28
|
+
const x = facts.permissions;
|
|
29
|
+
return {
|
|
30
|
+
lines: [line(anchor, `${x.catalog.value.size} catalog names, ${x.rows.value.size} rows`)],
|
|
31
|
+
json: { catalog: sorted(x.catalog.value), rows: byKey([...x.rows.value].map(([n, r]) => [n, sorted(r.keys)])) },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
case "scenarios": {
|
|
35
|
+
const x = facts.scenarios;
|
|
36
|
+
return { lines: [line(anchor, `${x.ids.size} scenarios`)], json: byKey([...x.cites].map(([id, c]) => [id, sorted(c)])) };
|
|
37
|
+
}
|
|
38
|
+
case "stateMachines": {
|
|
39
|
+
const x = facts.stateMachines;
|
|
40
|
+
return {
|
|
41
|
+
lines: [...x].map(([o, m]) => line(m.source.anchor, m.value === null ? "null" : `${m.value.nodes.length} states, ${m.value.edges.length} edges`, `.${o}`)),
|
|
42
|
+
json: byKey([...x].map(([o, m]) => [o, m.value === null ? null : { nodes: sorted(m.value.nodes), initial: sorted(m.value.initial), edges: sorted(m.value.edges) }])),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
case "transitionTriggers": {
|
|
46
|
+
const x = facts.transitionTriggers;
|
|
47
|
+
const edges = [...x.values()].reduce((n, m) => n + m.size, 0);
|
|
48
|
+
return { lines: [line(anchor, `${edges} edges`)], json: byKey([...x].map(([o, m]) => [o, byKey([...m].map(([e, t]) => [e, sorted(t.value)]))])) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const show = (x) => (x === undefined ? "(absent)" : JSON.stringify(x));
|
|
53
|
+
/** Differences between the inventory and the parse result, one `DIFF <path>: inventory <x>, document <y>` line each. An array is compared as a set of strings. */
|
|
54
|
+
function diff(path, inv, doc, out) {
|
|
55
|
+
if (Array.isArray(inv) && Array.isArray(doc) && inv.every((x) => typeof x === "string") && doc.every((x) => typeof x === "string")) {
|
|
56
|
+
if (JSON.stringify(sorted(inv)) !== JSON.stringify(sorted(doc)))
|
|
57
|
+
out.push(`DIFF ${path}: inventory ${show(sorted(inv))}, document ${show(sorted(doc))}`);
|
|
58
|
+
}
|
|
59
|
+
else if (isObj(inv) && isObj(doc)) {
|
|
60
|
+
for (const k of sorted(new Set([...Object.keys(inv), ...Object.keys(doc)])))
|
|
61
|
+
diff(`${path}.${k}`, inv[k], doc[k], out);
|
|
62
|
+
}
|
|
63
|
+
else if (JSON.stringify(inv) !== JSON.stringify(doc))
|
|
64
|
+
out.push(`DIFF ${path}: inventory ${show(inv)}, document ${show(doc)}`);
|
|
65
|
+
}
|
|
66
|
+
/** The inventory file: `{"conventionVersion": 1, "facts": {...}, "fields": {...}, "prose": [...]}`. Throws on any other shape. */
|
|
67
|
+
function readInventory(file) {
|
|
68
|
+
const fail = (problem) => {
|
|
69
|
+
throw new Error(`${file}: ${problem}`);
|
|
70
|
+
};
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(readFileSync(resolve(file), "utf8"));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return fail(`not valid JSON: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
if (!isObj(raw))
|
|
79
|
+
return fail("the root must be an object");
|
|
80
|
+
for (const k of Object.keys(raw))
|
|
81
|
+
if (!["conventionVersion", "facts", "fields", "prose"].includes(k))
|
|
82
|
+
fail(`${k}: unknown key`);
|
|
83
|
+
if (raw.conventionVersion !== 1)
|
|
84
|
+
fail("conventionVersion: must be 1");
|
|
85
|
+
if (!isObj(raw.facts))
|
|
86
|
+
fail("facts: must be an object");
|
|
87
|
+
if (!isObj(raw.fields))
|
|
88
|
+
fail("fields: must be an object");
|
|
89
|
+
if (!Array.isArray(raw.prose))
|
|
90
|
+
fail("prose: must be an array");
|
|
91
|
+
return { facts: raw.facts, fields: raw.fields };
|
|
92
|
+
}
|
|
93
|
+
/** `avouch facts`: parses the documents with the built-in Markdown adapter, with no ontology, and reports what it read. */
|
|
94
|
+
export async function runFacts(args) {
|
|
95
|
+
if (wantsHelp(args, ["--config", "--adapter", "--inventory"])) {
|
|
96
|
+
console.log(FACTS_USAGE);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
let opts;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = parseOptions(args, ["--config", "--adapter", "--inventory"], ["--json"]);
|
|
102
|
+
expectPositionals(parsed.pos, 0);
|
|
103
|
+
opts = parsed.opts;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error((error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " "));
|
|
107
|
+
console.error(FACTS_USAGE);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
const adapter = resolveMarkdownAdapter({ config: opts.get("--config"), adapter: opts.get("--adapter") });
|
|
111
|
+
const inventory = opts.has("--inventory") ? readInventory(opts.get("--inventory")) : null;
|
|
112
|
+
const docs = readDocs(adapter);
|
|
113
|
+
const facts = markdownFacts(docs, adapter.config);
|
|
114
|
+
const lines = [];
|
|
115
|
+
const factsJson = {};
|
|
116
|
+
for (const name of NAMES) {
|
|
117
|
+
const d = describeFact(name, facts, adapter.config);
|
|
118
|
+
if (d === null)
|
|
119
|
+
lines.push(`FACT ${name}: not configured`);
|
|
120
|
+
else {
|
|
121
|
+
lines.push(...d.lines);
|
|
122
|
+
factsJson[name] = d.json;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const fieldsJson = {};
|
|
126
|
+
const warnings = [];
|
|
127
|
+
for (const b of markdownTextBlocks(docs)) {
|
|
128
|
+
const key = `${b.anchor}#${b.first}`;
|
|
129
|
+
lines.push(`FIELDS ${key}: ${b.fields.size} field(s)`);
|
|
130
|
+
for (const s of b.strays)
|
|
131
|
+
warnings.push(`${b.doc}:${s.line}: ${key}: line is not a field line: ${s.text}`);
|
|
132
|
+
if (Object.hasOwn(fieldsJson, key))
|
|
133
|
+
warnings.push(`${b.doc}:${b.line}: ${key}: another text block has the same section anchor and first line; this one is not in the fields`);
|
|
134
|
+
else
|
|
135
|
+
fieldsJson[key] = byKey(b.fields);
|
|
136
|
+
}
|
|
137
|
+
lines.push(...warnings.map((w) => `WARN ${w}`));
|
|
138
|
+
const diagnostics = facts.diagnostics.map(({ rule, kind, key, msg }) => ({ rule, kind, key, msg }));
|
|
139
|
+
lines.push(...diagnostics.map((d) => `DIAG ${d.rule} ${d.kind} ${d.key}: ${d.msg}`));
|
|
140
|
+
const differences = [];
|
|
141
|
+
if (inventory !== null) {
|
|
142
|
+
diff("facts", inventory.facts, factsJson, differences);
|
|
143
|
+
diff("fields", inventory.fields, fieldsJson, differences);
|
|
144
|
+
}
|
|
145
|
+
const ok = diagnostics.length === 0 && differences.length === 0;
|
|
146
|
+
if (opts.get("--json") === true) {
|
|
147
|
+
console.log(JSON.stringify({ facts: factsJson, fields: fieldsJson, warnings, diagnostics, ...(inventory !== null ? { differences } : {}) }, null, 2));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
for (const l of lines)
|
|
151
|
+
console.log(l);
|
|
152
|
+
for (const d of differences)
|
|
153
|
+
console.log(d);
|
|
154
|
+
console.log(diagnostics.length === 0 ? "OK: 0 diagnostic(s)" : `FAIL: ${diagnostics.length} diagnostic(s)`);
|
|
155
|
+
}
|
|
156
|
+
return ok ? 0 : 1;
|
|
157
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const GATE_USAGE = "usage: avouch gate verify [-f <ontology.yaml>] --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--check] [--config <avouch.config.json>] [--adapter <module|config.json>]";
|
|
2
|
+
export declare const GATE_DOCS_USAGE = "usage: avouch gate docs [--config <avouch.config.json>] [--json]";
|
|
3
|
+
export declare function runGate(args: string[]): Promise<number>;
|