@invariant-ui/cli 0.0.0-stage → 0.1.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/LICENSE +105 -0
- package/README.md +248 -2
- package/dist/agent-kit/apply.js +232 -0
- package/dist/agent-kit/guidance.js +236 -0
- package/dist/agent-kit/index.js +5 -0
- package/dist/agent-kit/integrations.js +121 -0
- package/dist/agent-kit/plugin.js +115 -0
- package/dist/artifact-generator/index.js +75 -0
- package/dist/browser-verify/driver.js +2 -0
- package/dist/browser-verify/index.js +4 -0
- package/dist/browser-verify/playwright.js +82 -0
- package/dist/browser-verify/runner.js +175 -0
- package/dist/browser-verify/spec.js +103 -0
- package/dist/cli/args.js +77 -0
- package/dist/cli/baseline.js +64 -0
- package/dist/cli/cli.js +121 -0
- package/dist/cli/commands-browser.js +104 -0
- package/dist/cli/commands-discover.js +312 -0
- package/dist/cli/commands-enforcement.js +35 -0
- package/dist/cli/commands-governance.js +179 -0
- package/dist/cli/commands-hook.js +269 -0
- package/dist/cli/commands-patterns.js +324 -0
- package/dist/cli/commands-setup.js +485 -0
- package/dist/cli/commands-validate.js +157 -0
- package/dist/cli/commands.js +517 -0
- package/dist/cli/config.js +197 -0
- package/dist/cli/diff.js +66 -0
- package/dist/cli/enforcement-log.js +83 -0
- package/dist/cli/enforcement-report.js +96 -0
- package/dist/cli/errors.js +10 -0
- package/dist/cli/format.js +22 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/io.js +2 -0
- package/dist/cli/main.js +31 -0
- package/dist/cli/pipeline.js +262 -0
- package/dist/cli/publish.js +77 -0
- package/dist/cli/report.js +127 -0
- package/dist/cli/starter-rules.js +57 -0
- package/dist/cli/store.js +60 -0
- package/dist/cli/version.js +5 -0
- package/dist/cli/watch.js +68 -0
- package/dist/cli/workflow.js +52 -0
- package/dist/compiler-angular/adapter.js +115 -0
- package/dist/compiler-angular/analyze.js +222 -0
- package/dist/compiler-angular/classes.js +446 -0
- package/dist/compiler-angular/declared.js +285 -0
- package/dist/compiler-angular/emit.js +183 -0
- package/dist/compiler-angular/index.js +4 -0
- package/dist/compiler-angular/template.js +142 -0
- package/dist/compiler-core/adapter.js +2 -0
- package/dist/compiler-core/adapters/inventory.js +312 -0
- package/dist/compiler-core/compile.js +148 -0
- package/dist/compiler-core/component-facts.js +123 -0
- package/dist/compiler-core/discovery/docs.js +66 -0
- package/dist/compiler-core/discovery/entries.js +270 -0
- package/dist/compiler-core/discovery/exports.js +207 -0
- package/dist/compiler-core/discovery/graph.js +59 -0
- package/dist/compiler-core/discovery/index.js +103 -0
- package/dist/compiler-core/discovery/packages.js +148 -0
- package/dist/compiler-core/discovery/schema.js +144 -0
- package/dist/compiler-core/discovery/storybook.js +232 -0
- package/dist/compiler-core/discovery/support.js +82 -0
- package/dist/compiler-core/discovery/tokens.js +186 -0
- package/dist/compiler-core/discovery/tooling.js +146 -0
- package/dist/compiler-core/fs.js +89 -0
- package/dist/compiler-core/glob.js +3 -0
- package/dist/compiler-core/index.js +16 -0
- package/dist/compiler-core/ir-builder.js +68 -0
- package/dist/compiler-core/json-schema.js +11 -0
- package/dist/compiler-core/testing/index.js +81 -0
- package/dist/compiler-core/ts/ast.js +166 -0
- package/dist/compiler-core/ts/members.js +163 -0
- package/dist/compiler-core/ts/program.js +81 -0
- package/dist/compiler-core/ts/static-eval.js +145 -0
- package/dist/compiler-core/ts/tsconfig-paths.js +60 -0
- package/dist/compiler-core/walk.js +46 -0
- package/dist/compiler-ir/index.js +4 -0
- package/dist/compiler-ir/ir.js +67 -0
- package/dist/compiler-ir/json-schema.js +6 -0
- package/dist/compiler-ir/validate.js +116 -0
- package/dist/compiler-react/adapter.js +147 -0
- package/dist/compiler-react/analyze.js +199 -0
- package/dist/compiler-react/ast.js +105 -0
- package/dist/compiler-react/behavior.js +75 -0
- package/dist/compiler-react/detect.js +558 -0
- package/dist/compiler-react/emit.js +188 -0
- package/dist/compiler-react/index.js +2 -0
- package/dist/compiler-react/program.js +58 -0
- package/dist/compiler-react/props.js +157 -0
- package/dist/compiler-react/styling.js +138 -0
- package/dist/compiler-react/usage.js +116 -0
- package/dist/compiler-react/variants.js +111 -0
- package/dist/compiler-storybook/adapter.js +253 -0
- package/dist/compiler-storybook/csf.js +521 -0
- package/dist/compiler-storybook/emit.js +202 -0
- package/dist/compiler-storybook/index-json.js +41 -0
- package/dist/compiler-storybook/index.js +5 -0
- package/dist/compiler-storybook/mdx.js +120 -0
- package/dist/compiler-storybook/static-eval.js +145 -0
- package/dist/compiler-tokens/adapter.js +158 -0
- package/dist/compiler-tokens/css.js +59 -0
- package/dist/compiler-tokens/index.js +5 -0
- package/dist/compiler-tokens/json-lines.js +106 -0
- package/dist/compiler-tokens/json-tokens.js +52 -0
- package/dist/compiler-vue/adapter.js +107 -0
- package/dist/compiler-vue/analyze.js +344 -0
- package/dist/compiler-vue/declared.js +343 -0
- package/dist/compiler-vue/emit.js +129 -0
- package/dist/compiler-vue/index.js +5 -0
- package/dist/compiler-vue/script.js +472 -0
- package/dist/compiler-vue/sfc.js +44 -0
- package/dist/compiler-vue/template.js +73 -0
- package/dist/contract/archetype-checks.js +153 -0
- package/dist/contract/components.js +102 -0
- package/dist/contract/conflicts.js +39 -0
- package/dist/contract/contract.js +46 -0
- package/dist/contract/evidence.js +84 -0
- package/dist/contract/fact-path.js +39 -0
- package/dist/contract/facts.js +62 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/integrity.js +21 -0
- package/dist/contract/json-schema.js +11 -0
- package/dist/contract/knowledge.js +95 -0
- package/dist/contract/pattern-checks.js +254 -0
- package/dist/contract/patterns.js +225 -0
- package/dist/contract/primitives.js +81 -0
- package/dist/contract/sources.js +30 -0
- package/dist/contract/tokens.js +41 -0
- package/dist/contract/validate.js +253 -0
- package/dist/contract-query/archetype.js +188 -0
- package/dist/contract-query/index.js +6 -0
- package/dist/contract-query/patterns.js +298 -0
- package/dist/contract-query/scan.js +164 -0
- package/dist/contract-query/search.js +132 -0
- package/dist/contract-query/system.js +148 -0
- package/dist/contract-query/validate-code.js +239 -0
- package/dist/contract-query/validate-usage.js +150 -0
- package/dist/contract-query/views.js +288 -0
- package/dist/governance/decisions.js +229 -0
- package/dist/governance/index.js +5 -0
- package/dist/governance/patterns-file.js +156 -0
- package/dist/governance/patterns.js +510 -0
- package/dist/governance/policies.js +148 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/loader.js +70 -0
- package/dist/mcp/protocol.js +27 -0
- package/dist/mcp/resources.js +138 -0
- package/dist/mcp/server.js +177 -0
- package/dist/mcp/stdio.js +28 -0
- package/dist/mcp/tools.js +267 -0
- package/dist/reconciliation/code.js +62 -0
- package/dist/reconciliation/components.js +401 -0
- package/dist/reconciliation/conflicts.js +126 -0
- package/dist/reconciliation/facts.js +41 -0
- package/dist/reconciliation/index.js +7 -0
- package/dist/reconciliation/reconcile.js +134 -0
- package/dist/reconciliation/resolve.js +158 -0
- package/dist/reconciliation/review.js +81 -0
- package/dist/reconciliation/storybook.js +370 -0
- package/dist/reconciliation/tokens.js +179 -0
- package/dist/reconciliation/util.js +55 -0
- package/dist/semantic-engine/additive.js +51 -0
- package/dist/semantic-engine/compounds.js +91 -0
- package/dist/semantic-engine/documents.js +157 -0
- package/dist/semantic-engine/enrich.js +42 -0
- package/dist/semantic-engine/index.js +7 -0
- package/dist/semantic-engine/infer.js +38 -0
- package/dist/semantic-engine/tokens.js +120 -0
- package/dist/shared/canonical.js +65 -0
- package/dist/shared/diagnostic.js +78 -0
- package/dist/shared/glob.js +98 -0
- package/dist/shared/index.js +4 -0
- package/dist/shared/json.js +2 -0
- package/dist/validation/card.js +50 -0
- package/dist/validation/discovery/arrangements.js +162 -0
- package/dist/validation/discovery/discover.js +70 -0
- package/dist/validation/discovery/index.js +8 -0
- package/dist/validation/discovery/model.js +127 -0
- package/dist/validation/discovery/promote.js +24 -0
- package/dist/validation/discovery/reviews.js +63 -0
- package/dist/validation/discovery/shapes.js +47 -0
- package/dist/validation/discovery/similar-files.js +146 -0
- package/dist/validation/discovery/util.js +47 -0
- package/dist/validation/index.js +11 -0
- package/dist/validation/interaction-facts.js +341 -0
- package/dist/validation/interaction-workflows.js +633 -0
- package/dist/validation/literals.js +251 -0
- package/dist/validation/pattern-rules.js +608 -0
- package/dist/validation/policy.js +241 -0
- package/dist/validation/report.js +163 -0
- package/dist/validation/rules.js +269 -0
- package/dist/validation/scan.js +295 -0
- package/dist/validation/text.js +34 -0
- package/dist/validation/validate-code.js +513 -0
- package/dist/validation/validate-usage.js +196 -0
- package/package.json +38 -4
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { discoverProject, nodeFs } from "../compiler-core/index.js";
|
|
5
|
+
import { nearest, RULES } from "../validation/index.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { CliError } from "./errors.js";
|
|
8
|
+
export const CONFIG_FILE = "invariant.config.json";
|
|
9
|
+
export const DEFAULT_OUTPUT = "invariant";
|
|
10
|
+
const CODE_CONFIGS = [
|
|
11
|
+
"invariant.config.ts",
|
|
12
|
+
"invariant.config.mts",
|
|
13
|
+
"invariant.config.mjs",
|
|
14
|
+
"invariant.config.js"
|
|
15
|
+
];
|
|
16
|
+
/** Source types the compiler can read today. zeroheight and Figma are planned but not implemented. */
|
|
17
|
+
export const SOURCE_TYPES = ["react", "vue", "angular", "storybook", "tokens"];
|
|
18
|
+
const PLANNED_TYPES = ["zeroheight", "figma"];
|
|
19
|
+
const slugRe = /^[a-z0-9][a-z0-9._-]*$/;
|
|
20
|
+
/** A project-relative path: no absolute paths, no drive letters, no backslashes, no `..`. */
|
|
21
|
+
export function isSafeRelativePath(p) {
|
|
22
|
+
return (p !== "" &&
|
|
23
|
+
!p.startsWith("/") &&
|
|
24
|
+
!/^[A-Za-z]:/.test(p) &&
|
|
25
|
+
!p.includes("\\") &&
|
|
26
|
+
!p.split("/").includes(".."));
|
|
27
|
+
}
|
|
28
|
+
const relPath = z.string().refine(isSafeRelativePath, "must be a relative path inside the project (no '..')");
|
|
29
|
+
export const configSchema = z.strictObject({
|
|
30
|
+
name: z.string().min(1),
|
|
31
|
+
contractId: z.string().regex(slugRe).max(64).optional(),
|
|
32
|
+
description: z.string().min(1).optional(),
|
|
33
|
+
sources: z.array(z.strictObject({
|
|
34
|
+
/** A label for this entry. Compiled source ids come from the adapters; `analyze` shows the mapping. */
|
|
35
|
+
id: z.string().regex(slugRe).max(64),
|
|
36
|
+
type: z.enum(SOURCE_TYPES),
|
|
37
|
+
/** Limits the entry to sources found at or below this directory. Omitted means the whole project. */
|
|
38
|
+
path: relPath.optional(),
|
|
39
|
+
/**
|
|
40
|
+
* Read the installed package at `path` (for example `node_modules/@acme/ui`) as a source, although it
|
|
41
|
+
* sits under node_modules. Needs `path`.
|
|
42
|
+
*/
|
|
43
|
+
declarations: z.boolean().optional()
|
|
44
|
+
})),
|
|
45
|
+
output: z.strictObject({ directory: relPath }).optional(),
|
|
46
|
+
/**
|
|
47
|
+
* For an application that uses a design system it does not contain: the folder holding the contract the library
|
|
48
|
+
* ships (for example `node_modules/@acme/ui/invariant`). `compile` then starts from that contract instead of reading
|
|
49
|
+
* sources, and adds this project's own patterns, policies and decisions on top.
|
|
50
|
+
*/
|
|
51
|
+
extends: z.strictObject({ contract: relPath }).optional(),
|
|
52
|
+
validation: z
|
|
53
|
+
.strictObject({
|
|
54
|
+
/** Severity per rule code, or `off`. See `invariant rules`. */
|
|
55
|
+
rules: z.record(z.string(), z.enum(["off", "info", "warning", "error"])).optional(),
|
|
56
|
+
/** Globs of files `validate` skips when walking directories. */
|
|
57
|
+
ignore: z.array(z.string().min(1).max(300)).max(100).optional(),
|
|
58
|
+
failOn: z.enum(["error", "warning", "never"]).optional()
|
|
59
|
+
})
|
|
60
|
+
.optional(),
|
|
61
|
+
governance: z
|
|
62
|
+
.strictObject({
|
|
63
|
+
/** If present, only these names may record decisions. Names are asserted, not authenticated. */
|
|
64
|
+
approvers: z.array(z.string().min(1).max(200)).max(200).optional(),
|
|
65
|
+
policiesFile: relPath.optional(),
|
|
66
|
+
patternsFile: relPath.optional(),
|
|
67
|
+
decisionsFile: relPath.optional(),
|
|
68
|
+
candidateReviewsFile: relPath.optional()
|
|
69
|
+
})
|
|
70
|
+
.optional(),
|
|
71
|
+
enforcement: z
|
|
72
|
+
.strictObject({
|
|
73
|
+
/**
|
|
74
|
+
* What `invariant hook` does when an AI agent writes code that breaks the contract. `gate-errors` blocks
|
|
75
|
+
* the write when error-severity findings remain after the baseline and rule overrides; `warn` never blocks
|
|
76
|
+
* and reports everything after the write; `off` checks nothing. Warnings never block.
|
|
77
|
+
*/
|
|
78
|
+
hooks: z.enum(["gate-errors", "warn", "off"]).optional()
|
|
79
|
+
})
|
|
80
|
+
.optional()
|
|
81
|
+
});
|
|
82
|
+
export function outputDirectory(config) {
|
|
83
|
+
return config?.output?.directory ?? DEFAULT_OUTPUT;
|
|
84
|
+
}
|
|
85
|
+
export function validateConfig(raw) {
|
|
86
|
+
const sources = raw?.sources;
|
|
87
|
+
if (Array.isArray(sources)) {
|
|
88
|
+
for (const s of sources) {
|
|
89
|
+
const type = s?.type;
|
|
90
|
+
if (typeof type === "string" && PLANNED_TYPES.includes(type))
|
|
91
|
+
throw new CliError(`source type "${type}" is not supported yet. Supported: ${SOURCE_TYPES.join(", ")}. Remove the entry, or wait for the ${type} compiler.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const parsed = configSchema.safeParse(raw);
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
97
|
+
throw new CliError(`invalid configuration:\n${lines.join("\n")}`);
|
|
98
|
+
}
|
|
99
|
+
for (const code of Object.keys(parsed.data.validation?.rules ?? {})) {
|
|
100
|
+
if (RULES.some((r) => r.code === code))
|
|
101
|
+
continue;
|
|
102
|
+
const close = nearest(code, RULES.map((r) => r.code));
|
|
103
|
+
throw new CliError(`invalid configuration: validation.rules has unknown rule "${code}"${close.length > 0 ? `. Did you mean ${close.join(" or ")}?` : ""} (see \`invariant rules\`)`);
|
|
104
|
+
}
|
|
105
|
+
const ids = new Set();
|
|
106
|
+
for (const s of parsed.data.sources) {
|
|
107
|
+
if (s.declarations && s.path === undefined)
|
|
108
|
+
throw new CliError(`invalid configuration: source "${s.id}" sets declarations but has no path`);
|
|
109
|
+
if (ids.has(s.id))
|
|
110
|
+
throw new CliError(`invalid configuration: duplicate source id "${s.id}"`);
|
|
111
|
+
ids.add(s.id);
|
|
112
|
+
}
|
|
113
|
+
return parsed.data;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Finds and loads the configuration. JSON is read as data. A TypeScript or JavaScript config is code, and
|
|
117
|
+
* loading it runs it, so it is only loaded when the user passes `--trust-config`.
|
|
118
|
+
*/
|
|
119
|
+
export async function loadConfig(input) {
|
|
120
|
+
const candidates = input.file
|
|
121
|
+
? [path.resolve(input.cwd, input.file)]
|
|
122
|
+
: [CONFIG_FILE, ...CODE_CONFIGS].map((f) => path.join(input.cwd, f));
|
|
123
|
+
const file = candidates.find((f) => existsSync(f));
|
|
124
|
+
if (!file) {
|
|
125
|
+
if (input.file)
|
|
126
|
+
throw new CliError(`configuration file not found: ${input.file}`);
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
if (file.endsWith(".json")) {
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new CliError(`${path.relative(input.cwd, file)} is not valid JSON: ${error.message}`);
|
|
136
|
+
}
|
|
137
|
+
return { config: validateConfig(raw), file, editable: true };
|
|
138
|
+
}
|
|
139
|
+
if (!input.trustConfig)
|
|
140
|
+
throw new CliError(`${path.relative(input.cwd, file)} is code, and loading it runs it. Re-run with --trust-config if you trust it, or use ${CONFIG_FILE}.`);
|
|
141
|
+
const module = (await import(pathToFileURL(file).href));
|
|
142
|
+
if (module.default === undefined)
|
|
143
|
+
throw new CliError(`${path.relative(input.cwd, file)} has no default export`);
|
|
144
|
+
return { config: validateConfig(module.default), file, editable: false };
|
|
145
|
+
}
|
|
146
|
+
export function writeConfig(file, config) {
|
|
147
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
148
|
+
}
|
|
149
|
+
export const slug = (text) => {
|
|
150
|
+
const s = text
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/^@/, "")
|
|
153
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
154
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
155
|
+
.replace(/-{2,}/g, "-")
|
|
156
|
+
.replace(/[-.]+$/, "");
|
|
157
|
+
return s === "" ? "source" : s;
|
|
158
|
+
};
|
|
159
|
+
export function isDirectory(p) {
|
|
160
|
+
try {
|
|
161
|
+
return statSync(p).isDirectory();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Proposes a configuration from what discovery finds: one entry per framework package, Storybook, tokens. */
|
|
168
|
+
export async function detectConfig(cwd) {
|
|
169
|
+
const project = await discoverProject({ fs: nodeFs(cwd) });
|
|
170
|
+
const sources = [];
|
|
171
|
+
const taken = new Set();
|
|
172
|
+
const add = (base, type, dir) => {
|
|
173
|
+
let id = slug(base);
|
|
174
|
+
for (let n = 2; taken.has(id); n++)
|
|
175
|
+
id = `${slug(base)}-${n}`;
|
|
176
|
+
taken.add(id);
|
|
177
|
+
sources.push({ id, type, ...(dir === "." ? {} : { path: dir }) });
|
|
178
|
+
};
|
|
179
|
+
const MANY = 5;
|
|
180
|
+
const packages = [...project.packages].sort((a, b) => (a.dir < b.dir ? -1 : 1));
|
|
181
|
+
for (const type of ["react", "vue", "angular"]) {
|
|
182
|
+
const using = packages.filter((p) => p.frameworks.some((f) => f.name === type));
|
|
183
|
+
// A repository with many packages of one framework gets one whole-project entry rather than a long list.
|
|
184
|
+
if (using.length > MANY)
|
|
185
|
+
add(type, type, ".");
|
|
186
|
+
else
|
|
187
|
+
for (const pkg of using)
|
|
188
|
+
add(`${pkg.name}-${type}`, type, pkg.dir);
|
|
189
|
+
}
|
|
190
|
+
for (const sb of project.storybooks)
|
|
191
|
+
add(`storybook-${sb.directory}`, "storybook", sb.directory);
|
|
192
|
+
if (project.tokenFiles.length > 0)
|
|
193
|
+
add("tokens", "tokens", ".");
|
|
194
|
+
const rootName = project.packages.find((p) => p.dir === ".")?.name;
|
|
195
|
+
return { name: rootName ?? (path.basename(cwd) || "design-system"), sources };
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=config.js.map
|
package/dist/cli/diff.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { canonicalStringify } from "../shared/index.js";
|
|
2
|
+
/** Collections of the contract that are keyed by `id` and compared entity by entity. */
|
|
3
|
+
const COLLECTIONS = [
|
|
4
|
+
"sources",
|
|
5
|
+
"themes",
|
|
6
|
+
"components",
|
|
7
|
+
"tokens",
|
|
8
|
+
"patterns",
|
|
9
|
+
"guidelines",
|
|
10
|
+
"constraints",
|
|
11
|
+
"policies",
|
|
12
|
+
"conflicts"
|
|
13
|
+
];
|
|
14
|
+
/** Fields that change on every compile or sync and never mean the sources changed. */
|
|
15
|
+
const VOLATILE = new Set(["lastSyncedAt"]);
|
|
16
|
+
const same = (a, b) => a === undefined || b === undefined ? a === b : canonicalStringify(a) === canonicalStringify(b);
|
|
17
|
+
function changedFields(a, b) {
|
|
18
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
19
|
+
return [...keys].filter((k) => !VOLATILE.has(k) && !same(a[k], b[k])).sort();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What differs between two compiled contracts, by collection and entity id. `metadata.compiledAt` and
|
|
23
|
+
* `sources[].lastSyncedAt` are ignored: they are carried but never part of the content hash.
|
|
24
|
+
*/
|
|
25
|
+
export function diffContracts(previous, next) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const collection of COLLECTIONS) {
|
|
28
|
+
const before = new Map(previous[collection].map((e) => [e.id, e]));
|
|
29
|
+
const after = new Map(next[collection].map((e) => [e.id, e]));
|
|
30
|
+
const added = [...after.keys()].filter((id) => !before.has(id)).sort();
|
|
31
|
+
const removed = [...before.keys()].filter((id) => !after.has(id)).sort();
|
|
32
|
+
const changed = [];
|
|
33
|
+
for (const [id, a] of before) {
|
|
34
|
+
const b = after.get(id);
|
|
35
|
+
if (!b)
|
|
36
|
+
continue;
|
|
37
|
+
const fields = changedFields(a, b);
|
|
38
|
+
if (fields.length > 0)
|
|
39
|
+
changed.push({ id, fields });
|
|
40
|
+
}
|
|
41
|
+
changed.sort((x, y) => x.id.localeCompare(y.id));
|
|
42
|
+
if (added.length + removed.length + changed.length > 0)
|
|
43
|
+
out.push({ collection, added, removed, changed });
|
|
44
|
+
}
|
|
45
|
+
const meta = changedFields({ id: "metadata", ...previous.metadata, compiledAt: undefined }, { id: "metadata", ...next.metadata, compiledAt: undefined }).filter((k) => k !== "compiledAt" && k !== "id");
|
|
46
|
+
if (meta.length > 0)
|
|
47
|
+
out.push({ collection: "metadata", added: [], removed: [], changed: [{ id: "metadata", fields: meta }] });
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** Human-readable lines, at most `limit` entity lines per collection. */
|
|
51
|
+
export function formatDiff(diff, limit = 10) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const d of diff) {
|
|
54
|
+
lines.push(`${d.collection}: +${d.added.length} -${d.removed.length} ~${d.changed.length}`);
|
|
55
|
+
const entries = [
|
|
56
|
+
...d.added.map((id) => ` + ${id}`),
|
|
57
|
+
...d.removed.map((id) => ` - ${id}`),
|
|
58
|
+
...d.changed.map((c) => ` ~ ${c.id} (${c.fields.join(", ")})`)
|
|
59
|
+
];
|
|
60
|
+
lines.push(...entries.slice(0, limit));
|
|
61
|
+
if (entries.length > limit)
|
|
62
|
+
lines.push(` ... ${entries.length - limit} more`);
|
|
63
|
+
}
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
/**
|
|
5
|
+
* The enforcement log: what `invariant hook` checked, one JSON object per line, under `.invariant/` in the
|
|
6
|
+
* project. Only the user-installed hook writes it; the MCP server stays read-only (ADR 0003).
|
|
7
|
+
*
|
|
8
|
+
* Appends use a single O_APPEND write of one short line, which POSIX keeps whole when hooks run concurrently
|
|
9
|
+
* (lines stay under 4 KB: findings are capped and their text truncated). The log holds findings and file
|
|
10
|
+
* paths, never file contents.
|
|
11
|
+
*/
|
|
12
|
+
export const LOG_DIR = ".invariant";
|
|
13
|
+
export const LOG_FILE = `${LOG_DIR}/enforcement.jsonl`;
|
|
14
|
+
const MAX_FINDINGS = 10;
|
|
15
|
+
const MAX_TEXT = 200;
|
|
16
|
+
export const findingSchema = z.object({
|
|
17
|
+
code: z.string(),
|
|
18
|
+
severity: z.enum(["error", "warning", "info"]),
|
|
19
|
+
message: z.string(),
|
|
20
|
+
line: z.number().optional(),
|
|
21
|
+
fix: z.string().optional()
|
|
22
|
+
});
|
|
23
|
+
export const eventSchema = z.object({
|
|
24
|
+
v: z.literal(1),
|
|
25
|
+
time: z.string(),
|
|
26
|
+
session: z.string(),
|
|
27
|
+
file: z.string(),
|
|
28
|
+
/** `blocked`: the write was denied. `warned`: it landed with findings. `passed`: it landed clean. */
|
|
29
|
+
outcome: z.enum(["blocked", "warned", "passed"]),
|
|
30
|
+
findings: z.array(findingSchema),
|
|
31
|
+
/** Findings beyond the cap, so the count stays honest. */
|
|
32
|
+
omitted: z.number().int().nonnegative(),
|
|
33
|
+
/** Component uses checked, for clean passes. */
|
|
34
|
+
checked: z.number().int().nonnegative()
|
|
35
|
+
});
|
|
36
|
+
const clip = (s) => (s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT - 1)}…` : s);
|
|
37
|
+
/** Appends one event. The caller decides what a failure means; the hook notes it and carries on. */
|
|
38
|
+
export function appendEvent(root, input) {
|
|
39
|
+
const dir = path.join(root, LOG_DIR);
|
|
40
|
+
if (!existsSync(dir)) {
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
// Local state: ignore the whole directory without touching the project's own .gitignore.
|
|
43
|
+
writeFileSync(path.join(dir, ".gitignore"), "*\n");
|
|
44
|
+
}
|
|
45
|
+
const event = {
|
|
46
|
+
v: 1,
|
|
47
|
+
time: input.time,
|
|
48
|
+
session: input.session,
|
|
49
|
+
file: input.file,
|
|
50
|
+
outcome: input.outcome,
|
|
51
|
+
findings: input.findings.slice(0, MAX_FINDINGS).map((f) => ({
|
|
52
|
+
code: f.code,
|
|
53
|
+
severity: f.severity,
|
|
54
|
+
message: clip(f.message),
|
|
55
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
56
|
+
...(f.suggestion ? { fix: clip(f.suggestion) } : {})
|
|
57
|
+
})),
|
|
58
|
+
omitted: Math.max(0, input.findings.length - MAX_FINDINGS),
|
|
59
|
+
checked: input.checked
|
|
60
|
+
};
|
|
61
|
+
appendFileSync(path.join(root, LOG_FILE), `${JSON.stringify(event)}\n`);
|
|
62
|
+
}
|
|
63
|
+
/** Reads every well-formed event; a torn or foreign line is skipped, never fatal. */
|
|
64
|
+
export function readEvents(root) {
|
|
65
|
+
const file = path.join(root, LOG_FILE);
|
|
66
|
+
if (!existsSync(file))
|
|
67
|
+
return [];
|
|
68
|
+
const events = [];
|
|
69
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
70
|
+
if (!line.trim())
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = eventSchema.safeParse(JSON.parse(line));
|
|
74
|
+
if (parsed.success)
|
|
75
|
+
events.push(parsed.data);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// a torn line is skipped
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return events;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=enforcement-log.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { esc, STYLE } from "./report.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turns one session's events (in log order) into the timeline and its headline numbers. A catch is a blocked
|
|
4
|
+
* or warned write; it is fixed when a later write to the same file passes clean, outstanding otherwise.
|
|
5
|
+
*/
|
|
6
|
+
export function summarize(session, events) {
|
|
7
|
+
const open = new Map();
|
|
8
|
+
let caught = 0;
|
|
9
|
+
let fixed = 0;
|
|
10
|
+
const steps = [];
|
|
11
|
+
for (const e of events) {
|
|
12
|
+
const step = {
|
|
13
|
+
time: e.time,
|
|
14
|
+
file: e.file,
|
|
15
|
+
findings: e.findings,
|
|
16
|
+
omitted: e.omitted,
|
|
17
|
+
checked: e.checked,
|
|
18
|
+
resolves: []
|
|
19
|
+
};
|
|
20
|
+
if (e.outcome === "passed") {
|
|
21
|
+
const pending = open.get(e.file);
|
|
22
|
+
if (pending) {
|
|
23
|
+
fixed += pending.catches;
|
|
24
|
+
open.delete(e.file);
|
|
25
|
+
steps.push({ ...step, outcome: "fixed", resolves: [...new Set(pending.codes)] });
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
steps.push({ ...step, outcome: "passed" });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
caught++;
|
|
32
|
+
const pending = open.get(e.file) ?? { catches: 0, codes: [] };
|
|
33
|
+
pending.catches++;
|
|
34
|
+
pending.codes.push(...e.findings.map((f) => f.code));
|
|
35
|
+
open.set(e.file, pending);
|
|
36
|
+
steps.push({ ...step, outcome: e.outcome });
|
|
37
|
+
}
|
|
38
|
+
return { session, steps, writes: events.length, caught, fixed, outstanding: caught - fixed };
|
|
39
|
+
}
|
|
40
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
41
|
+
export function headline(s) {
|
|
42
|
+
return `${plural(s.writes, "write")} checked · ${plural(s.caught, "violation")} caught · ${s.fixed} fixed · ${s.outstanding} outstanding`;
|
|
43
|
+
}
|
|
44
|
+
const MARK = { error: "✗", warning: "⚠", info: "ℹ" };
|
|
45
|
+
export function renderText(s) {
|
|
46
|
+
const lines = [`Invariant enforcement: ${headline(s)}`, `session ${s.session}`, ""];
|
|
47
|
+
for (const step of s.steps) {
|
|
48
|
+
const uses = plural(step.checked, "component use");
|
|
49
|
+
const state = step.outcome === "fixed"
|
|
50
|
+
? `fixed (caught ${step.resolves.join(", ")}; now clean, ${uses} checked)`
|
|
51
|
+
: step.outcome === "passed"
|
|
52
|
+
? `passed (${uses} checked)`
|
|
53
|
+
: `${step.outcome} (${plural(step.findings.length + step.omitted, "finding")})`;
|
|
54
|
+
lines.push(`${step.time} ${step.file} ${state}`);
|
|
55
|
+
for (const f of step.findings) {
|
|
56
|
+
lines.push(` ${MARK[f.severity]} ${f.code}: ${f.message}${f.line !== undefined ? ` (line ${f.line})` : ""}`);
|
|
57
|
+
if (f.fix)
|
|
58
|
+
lines.push(` fix: ${f.fix}`);
|
|
59
|
+
}
|
|
60
|
+
if (step.omitted > 0)
|
|
61
|
+
lines.push(` …and ${step.omitted} more`);
|
|
62
|
+
}
|
|
63
|
+
if (s.steps.length === 0)
|
|
64
|
+
lines.push("No writes were checked in this session.");
|
|
65
|
+
return `${lines.join("\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
export function renderHtml(s) {
|
|
68
|
+
const rows = s.steps
|
|
69
|
+
.map((step) => {
|
|
70
|
+
const found = step.findings
|
|
71
|
+
.map((f) => `<div><span class="${esc(f.severity)}">${MARK[f.severity]}</span> <code>${esc(f.code)}</code> ${esc(f.message)}${f.line !== undefined ? ` (line ${esc(f.line)})` : ""}${f.fix ? `<br><small>fix: ${esc(f.fix)}</small>` : ""}</div>`)
|
|
72
|
+
.join("");
|
|
73
|
+
const detail = step.outcome === "fixed"
|
|
74
|
+
? `Caught ${step.resolves.map((c) => `<code>${esc(c)}</code>`).join(", ")}; now clean.`
|
|
75
|
+
: step.outcome === "passed"
|
|
76
|
+
? `Clean (${esc(plural(step.checked, "component use"))} checked).`
|
|
77
|
+
: found + (step.omitted > 0 ? `<div>…and ${esc(step.omitted)} more</div>` : "");
|
|
78
|
+
return `<tr><td>${esc(step.time)}</td><td><code>${esc(step.file)}</code></td><td>${esc(step.outcome)}</td><td>${detail}</td></tr>`;
|
|
79
|
+
})
|
|
80
|
+
.join("\n");
|
|
81
|
+
const table = s.steps.length === 0
|
|
82
|
+
? "<p>No writes were checked in this session.</p>"
|
|
83
|
+
: `<table><thead><tr><th>Time</th><th>File</th><th>Outcome</th><th>Detail</th></tr></thead><tbody>\n${rows}\n</tbody></table>`;
|
|
84
|
+
return `<!doctype html>
|
|
85
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
86
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
|
|
87
|
+
<title>Invariant enforcement report</title>
|
|
88
|
+
<style>${STYLE}</style></head><body>
|
|
89
|
+
<h1>Invariant enforcement</h1>
|
|
90
|
+
<p><strong>${esc(headline(s))}</strong></p>
|
|
91
|
+
<p>Session <code>${esc(s.session)}</code></p>
|
|
92
|
+
${table}
|
|
93
|
+
</body></html>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=enforcement-report.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A failure to report to the user, with the exit code to use: 1 for findings, 2 for bad usage or input. */
|
|
2
|
+
export class CliError extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
constructor(message, exitCode = 2) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CliError";
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function table(head, rows) {
|
|
2
|
+
const all = [head, ...rows.map((r) => r.map(String))];
|
|
3
|
+
const widths = head.map((_, i) => Math.max(...all.map((r) => (r[i] ?? "").length)));
|
|
4
|
+
const line = (r) => r
|
|
5
|
+
.map((c, i) => c.padEnd(widths[i] ?? 0))
|
|
6
|
+
.join(" ")
|
|
7
|
+
.trimEnd();
|
|
8
|
+
return [line(head), line(widths.map((w) => "-".repeat(w))), ...all.slice(1).map(line)].join("\n");
|
|
9
|
+
}
|
|
10
|
+
export function formatDiagnostic(d) {
|
|
11
|
+
const where = d.location ? ` (${d.location.path}${d.location.line ? `:${d.location.line}` : ""})` : "";
|
|
12
|
+
return `${d.severity.padEnd(7)} ${d.code}: ${d.message}${where}`;
|
|
13
|
+
}
|
|
14
|
+
export function countBySeverity(diagnostics) {
|
|
15
|
+
const n = (s) => diagnostics.filter((d) => d.severity === s).length;
|
|
16
|
+
return `${n("error")} errors, ${n("warning")} warnings, ${n("info")} info`;
|
|
17
|
+
}
|
|
18
|
+
export function shortValue(value, max = 40) {
|
|
19
|
+
const text = value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
|
20
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=format.js.map
|
package/dist/cli/io.js
ADDED
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { run } from "./cli.js";
|
|
4
|
+
const stop = new AbortController();
|
|
5
|
+
for (const sig of ["SIGINT", "SIGTERM"])
|
|
6
|
+
process.once(sig, () => stop.abort());
|
|
7
|
+
const code = await run(process.argv.slice(2), {
|
|
8
|
+
cwd: process.cwd(),
|
|
9
|
+
stdout: (text) => process.stdout.write(text),
|
|
10
|
+
stderr: (text) => process.stderr.write(text),
|
|
11
|
+
clock: () => new Date().toISOString(),
|
|
12
|
+
env: process.env,
|
|
13
|
+
input: process.stdin,
|
|
14
|
+
output: process.stdout,
|
|
15
|
+
signal: stop.signal,
|
|
16
|
+
...(process.stdin.isTTY && process.stdout.isTTY
|
|
17
|
+
? {
|
|
18
|
+
prompt: async (question) => {
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
try {
|
|
21
|
+
return await rl.question(question);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
rl.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
: {})
|
|
29
|
+
});
|
|
30
|
+
process.exitCode = code;
|
|
31
|
+
//# sourceMappingURL=main.js.map
|