@invariant-ui/cli 0.0.0-stage → 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/LICENSE +105 -0
- package/README.md +250 -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/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/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-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/search.js +132 -0
- package/dist/contract-query/system.js +148 -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,175 @@
|
|
|
1
|
+
import { allowedOrigins } from "./spec.js";
|
|
2
|
+
export const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
3
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
4
|
+
const describeStep = (s) => s.action === "press"
|
|
5
|
+
? `press ${s.key}`
|
|
6
|
+
: `${s.action} ${s.selector}${s.action === "fill" ? ` with "${s.value}"` : ""}`;
|
|
7
|
+
const message = (e) => e instanceof Error ? (e.message.split("\n")[0] ?? "failed") : String(e);
|
|
8
|
+
async function runStep(page, s) {
|
|
9
|
+
if (s.action === "click")
|
|
10
|
+
return page.click(s.selector);
|
|
11
|
+
if (s.action === "fill")
|
|
12
|
+
return page.fill(s.selector, s.value);
|
|
13
|
+
if (s.action === "press")
|
|
14
|
+
return page.press(s.key);
|
|
15
|
+
return page.waitFor(s.selector);
|
|
16
|
+
}
|
|
17
|
+
async function evaluate(page, e) {
|
|
18
|
+
switch (e.kind) {
|
|
19
|
+
case "visible": {
|
|
20
|
+
const ok = await page.isVisible(e.selector);
|
|
21
|
+
return ok
|
|
22
|
+
? { status: "pass", reason: `${e.selector} is visible` }
|
|
23
|
+
: {
|
|
24
|
+
status: "fail",
|
|
25
|
+
reason: `${e.selector} is not visible`,
|
|
26
|
+
suggestion: "Check the selector, and that the element renders and is not hidden."
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
case "hidden": {
|
|
30
|
+
const ok = !(await page.isVisible(e.selector));
|
|
31
|
+
return ok
|
|
32
|
+
? { status: "pass", reason: `${e.selector} is not visible` }
|
|
33
|
+
: { status: "fail", reason: `${e.selector} is visible, and should not be` };
|
|
34
|
+
}
|
|
35
|
+
case "text": {
|
|
36
|
+
const text = await page.text(e.selector);
|
|
37
|
+
if (text === null)
|
|
38
|
+
return { status: "fail", reason: `${e.selector} was not found` };
|
|
39
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
40
|
+
if (e.equals !== undefined)
|
|
41
|
+
return trimmed === e.equals
|
|
42
|
+
? { status: "pass", reason: `${e.selector} reads "${e.equals}"` }
|
|
43
|
+
: {
|
|
44
|
+
status: "fail",
|
|
45
|
+
reason: `${e.selector} reads "${trimmed.slice(0, 120)}", expected "${e.equals}"`
|
|
46
|
+
};
|
|
47
|
+
if (e.contains !== undefined)
|
|
48
|
+
return trimmed.includes(e.contains)
|
|
49
|
+
? { status: "pass", reason: `${e.selector} contains "${e.contains}"` }
|
|
50
|
+
: {
|
|
51
|
+
status: "fail",
|
|
52
|
+
reason: `${e.selector} reads "${trimmed.slice(0, 120)}", which lacks "${e.contains}"`
|
|
53
|
+
};
|
|
54
|
+
return { status: "fail", reason: "a text expectation needs equals or contains" };
|
|
55
|
+
}
|
|
56
|
+
case "focused": {
|
|
57
|
+
const ok = await page.isFocused(e.selector);
|
|
58
|
+
return ok
|
|
59
|
+
? { status: "pass", reason: `focus is on ${e.selector}` }
|
|
60
|
+
: {
|
|
61
|
+
status: "fail",
|
|
62
|
+
reason: `focus is on ${await page.activeElement()}, not ${e.selector}`,
|
|
63
|
+
suggestion: "Check what moves focus here, such as autofocus, a focus trap or a focus call after the action."
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
case "tab-order": {
|
|
67
|
+
for (const [i, sel] of e.selectors.entries()) {
|
|
68
|
+
await page.press("Tab");
|
|
69
|
+
if (!(await page.isFocused(sel)))
|
|
70
|
+
return {
|
|
71
|
+
status: "fail",
|
|
72
|
+
reason: `Tab ${i + 1} should reach ${sel}, but focus is on ${await page.activeElement()}`,
|
|
73
|
+
suggestion: "Check the order of focusable elements, tabindex values and anything that traps or skips focus."
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { status: "pass", reason: `Tab visits ${e.selectors.join(", then ")} in that order` };
|
|
77
|
+
}
|
|
78
|
+
case "contains": {
|
|
79
|
+
const ok = await page.contains(e.outer, e.inner);
|
|
80
|
+
return ok
|
|
81
|
+
? { status: "pass", reason: `${e.inner} is inside ${e.outer}` }
|
|
82
|
+
: { status: "fail", reason: `${e.inner} is not inside ${e.outer} in the rendered page` };
|
|
83
|
+
}
|
|
84
|
+
case "count": {
|
|
85
|
+
const n = await page.count(e.selector);
|
|
86
|
+
const low = e.min !== undefined && n < e.min;
|
|
87
|
+
const high = e.max !== undefined && n > e.max;
|
|
88
|
+
return low || high
|
|
89
|
+
? {
|
|
90
|
+
status: "fail",
|
|
91
|
+
reason: `${n} match ${e.selector}, expected ${e.min !== undefined ? `at least ${e.min}` : ""}${e.min !== undefined && e.max !== undefined ? " and " : ""}${e.max !== undefined ? `at most ${e.max}` : ""}`
|
|
92
|
+
}
|
|
93
|
+
: { status: "pass", reason: `${n} match ${e.selector}` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function runOne(driver, spec, scenario, viewport) {
|
|
98
|
+
const url = new URL(scenario.path, spec.baseUrl).toString();
|
|
99
|
+
const context = { browser: driver.browser, browserVersion: driver.version(), viewport, url };
|
|
100
|
+
const base = {
|
|
101
|
+
scenario: scenario.id,
|
|
102
|
+
level: "runtime-verified",
|
|
103
|
+
context,
|
|
104
|
+
...(scenario.pattern ? { pattern: scenario.pattern } : {}),
|
|
105
|
+
...(scenario.constraint ? { constraint: scenario.constraint } : {})
|
|
106
|
+
};
|
|
107
|
+
const everything = (status, reason, check, suggestion) => [
|
|
108
|
+
{ ...base, check, kind: "scenario", status, reason, ...(suggestion ? { suggestion } : {}) }
|
|
109
|
+
];
|
|
110
|
+
let page;
|
|
111
|
+
try {
|
|
112
|
+
page = await driver.open({
|
|
113
|
+
viewport,
|
|
114
|
+
allowedOrigins: allowedOrigins(spec),
|
|
115
|
+
timeoutMs: spec.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return everything("error", `could not open a page: ${message(e)}`, "open");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
try {
|
|
123
|
+
await page.goto(url);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return everything("error", `could not load ${url}: ${message(e)}`, "goto", "Is the app running at baseUrl?");
|
|
127
|
+
}
|
|
128
|
+
for (const [i, s] of scenario.steps.entries())
|
|
129
|
+
try {
|
|
130
|
+
await runStep(page, s);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return everything("error", `step ${i + 1} (${describeStep(s)}) failed: ${message(e)}`, `step ${i + 1}`, "The expectations were not checked. Fix the step or the selector.");
|
|
134
|
+
}
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const [i, e] of scenario.expect.entries()) {
|
|
137
|
+
try {
|
|
138
|
+
const r = await evaluate(page, e);
|
|
139
|
+
out.push({ ...base, check: `expect ${i + 1}`, kind: e.kind, ...r });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
out.push({
|
|
143
|
+
...base,
|
|
144
|
+
check: `expect ${i + 1}`,
|
|
145
|
+
kind: e.kind,
|
|
146
|
+
status: "error",
|
|
147
|
+
reason: `could not check: ${message(err)}`
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await page.close().catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Runs each scenario, once per viewport, in a fresh page. Results are runtime findings tied to the browser,
|
|
159
|
+
* its version, the viewport and the URL. The run opens no browser by itself: the driver does, and a caller that
|
|
160
|
+
* never builds a driver never touches one.
|
|
161
|
+
*/
|
|
162
|
+
export async function runBrowserChecks(driver, spec) {
|
|
163
|
+
const results = [];
|
|
164
|
+
for (const scenario of spec.scenarios)
|
|
165
|
+
for (const viewport of scenario.viewports ?? [DEFAULT_VIEWPORT])
|
|
166
|
+
results.push(...(await runOne(driver, spec, scenario, viewport)));
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
export const summarizeBrowser = (results) => {
|
|
170
|
+
const out = { pass: 0, fail: 0, error: 0 };
|
|
171
|
+
for (const r of results)
|
|
172
|
+
out[r.status]++;
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
//# sourceMappingURL=runner.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const BROWSER_SPEC_FILE = "invariant.browser.json";
|
|
3
|
+
export const BROWSER_RESULTS_FILE = "invariant.browser-results.json";
|
|
4
|
+
export const BROWSERS = ["chromium", "firefox", "webkit"];
|
|
5
|
+
/** Selectors are CSS or Playwright selector strings. A spec holds data only: it can never carry code to run. */
|
|
6
|
+
const selector = z.string().min(1).max(300);
|
|
7
|
+
const step = z.discriminatedUnion("action", [
|
|
8
|
+
z.strictObject({ action: z.literal("click"), selector }),
|
|
9
|
+
z.strictObject({ action: z.literal("fill"), selector, value: z.string().max(1000) }),
|
|
10
|
+
z.strictObject({ action: z.literal("press"), key: z.string().min(1).max(40) }),
|
|
11
|
+
z.strictObject({ action: z.literal("wait-for"), selector })
|
|
12
|
+
]);
|
|
13
|
+
const expectation = z.discriminatedUnion("kind", [
|
|
14
|
+
z.strictObject({ kind: z.literal("visible"), selector }),
|
|
15
|
+
z.strictObject({ kind: z.literal("hidden"), selector }),
|
|
16
|
+
z.strictObject({
|
|
17
|
+
kind: z.literal("text"),
|
|
18
|
+
selector,
|
|
19
|
+
equals: z.string().max(1000).optional(),
|
|
20
|
+
contains: z.string().max(1000).optional()
|
|
21
|
+
}),
|
|
22
|
+
z.strictObject({ kind: z.literal("focused"), selector }),
|
|
23
|
+
/** Pressing Tab from the current focus visits these, in this order. */
|
|
24
|
+
z.strictObject({ kind: z.literal("tab-order"), selectors: z.array(selector).min(1).max(50) }),
|
|
25
|
+
/** The element for `inner` is inside the element for `outer` in the rendered page. */
|
|
26
|
+
z.strictObject({ kind: z.literal("contains"), outer: selector, inner: selector }),
|
|
27
|
+
z.strictObject({
|
|
28
|
+
kind: z.literal("count"),
|
|
29
|
+
selector,
|
|
30
|
+
min: z.number().int().nonnegative().optional(),
|
|
31
|
+
max: z.number().int().nonnegative().optional()
|
|
32
|
+
})
|
|
33
|
+
]);
|
|
34
|
+
const viewport = z.strictObject({
|
|
35
|
+
width: z.number().int().min(200).max(4000),
|
|
36
|
+
height: z.number().int().min(200).max(4000)
|
|
37
|
+
});
|
|
38
|
+
export const scenarioSchema = z.strictObject({
|
|
39
|
+
id: z
|
|
40
|
+
.string()
|
|
41
|
+
.regex(/^[a-z0-9][a-z0-9._-]*$/)
|
|
42
|
+
.max(80),
|
|
43
|
+
description: z.string().min(1).max(500).optional(),
|
|
44
|
+
/** The approved pattern and constraint this scenario gives a runtime check for. Recorded, never inferred. */
|
|
45
|
+
pattern: z.string().min(1).max(200).optional(),
|
|
46
|
+
constraint: z.string().min(1).max(200).optional(),
|
|
47
|
+
/** A path on the base URL, starting with `/`. */
|
|
48
|
+
path: z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^\/[^\s]*$/)
|
|
51
|
+
.max(500),
|
|
52
|
+
/** One result is produced per viewport; no viewport means 1280 by 800. */
|
|
53
|
+
viewports: z.array(viewport).min(1).max(6).optional(),
|
|
54
|
+
steps: z.array(step).max(100).default([]),
|
|
55
|
+
expect: z.array(expectation).min(1).max(100)
|
|
56
|
+
});
|
|
57
|
+
export const browserSpecSchema = z.strictObject({
|
|
58
|
+
$schema: z.string().optional(),
|
|
59
|
+
version: z.literal(1),
|
|
60
|
+
baseUrl: z.url().max(300),
|
|
61
|
+
/** Origins besides the base URL the page may load from. Everything else is blocked. */
|
|
62
|
+
allowOrigins: z.array(z.url().max(300)).max(20).optional(),
|
|
63
|
+
browser: z.enum(BROWSERS).optional(),
|
|
64
|
+
timeoutMs: z.number().int().min(500).max(60000).optional(),
|
|
65
|
+
scenarios: z.array(scenarioSchema).min(1).max(200)
|
|
66
|
+
});
|
|
67
|
+
export function parseSpec(text, name = BROWSER_SPEC_FILE) {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(text);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { ok: false, message: `${name} is not valid JSON: ${error.message}` };
|
|
74
|
+
}
|
|
75
|
+
const parsed = browserSpecSchema.safeParse(raw);
|
|
76
|
+
if (!parsed.success) {
|
|
77
|
+
const i = parsed.error.issues[0];
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: `${name} is invalid at ${i?.path.join(".") || "(root)"}: ${i?.message ?? "schema error"}`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const ids = parsed.data.scenarios.map((s) => s.id);
|
|
84
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
85
|
+
if (dup)
|
|
86
|
+
return { ok: false, message: `${name}: scenario id "${dup}" is used twice` };
|
|
87
|
+
return { ok: true, value: parsed.data };
|
|
88
|
+
}
|
|
89
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
90
|
+
/** A run reaches only the base URL's origin and listed origins, and the base URL must be local unless listed. */
|
|
91
|
+
export function checkOrigins(spec) {
|
|
92
|
+
const base = new URL(spec.baseUrl);
|
|
93
|
+
if (!LOOPBACK.has(base.hostname) &&
|
|
94
|
+
!(spec.allowOrigins ?? []).some((o) => new URL(o).origin === base.origin))
|
|
95
|
+
return `baseUrl ${base.origin} is not local. To verify a remote site, list its origin in allowOrigins to say that is intended.`;
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
export function allowedOrigins(spec) {
|
|
99
|
+
return [
|
|
100
|
+
...new Set([new URL(spec.baseUrl).origin, ...(spec.allowOrigins ?? []).map((o) => new URL(o).origin)])
|
|
101
|
+
].sort();
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=spec.js.map
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
const OPTIONS = {
|
|
4
|
+
cwd: { type: "string" },
|
|
5
|
+
config: { type: "string" },
|
|
6
|
+
dir: { type: "string" },
|
|
7
|
+
"trust-config": { type: "boolean" },
|
|
8
|
+
json: { type: "boolean" },
|
|
9
|
+
help: { type: "boolean", short: "h" },
|
|
10
|
+
version: { type: "boolean", short: "v" },
|
|
11
|
+
force: { type: "boolean" },
|
|
12
|
+
id: { type: "string" },
|
|
13
|
+
to: { type: "string" },
|
|
14
|
+
fresh: { type: "boolean" },
|
|
15
|
+
watch: { type: "boolean" },
|
|
16
|
+
"keep-ir": { type: "boolean" },
|
|
17
|
+
severity: { type: "string" },
|
|
18
|
+
type: { type: "string" },
|
|
19
|
+
subject: { type: "string" },
|
|
20
|
+
evidence: { type: "boolean" },
|
|
21
|
+
command: { type: "string" },
|
|
22
|
+
"command-arg": { type: "string", multiple: true },
|
|
23
|
+
"dry-run": { type: "boolean" },
|
|
24
|
+
format: { type: "string" },
|
|
25
|
+
"fail-on": { type: "string" },
|
|
26
|
+
framework: { type: "string" },
|
|
27
|
+
as: { type: "string" },
|
|
28
|
+
role: { type: "string" },
|
|
29
|
+
reference: { type: "string" },
|
|
30
|
+
note: { type: "string" },
|
|
31
|
+
source: { type: "string" },
|
|
32
|
+
kind: { type: "string" },
|
|
33
|
+
category: { type: "string" },
|
|
34
|
+
status: { type: "string" },
|
|
35
|
+
direction: { type: "string" },
|
|
36
|
+
depth: { type: "string" },
|
|
37
|
+
variation: { type: "string" },
|
|
38
|
+
"min-files": { type: "string" },
|
|
39
|
+
"min-uses": { type: "string" },
|
|
40
|
+
similarity: { type: "string" },
|
|
41
|
+
all: { type: "boolean" },
|
|
42
|
+
name: { type: "string" },
|
|
43
|
+
spec: { type: "string" },
|
|
44
|
+
browser: { type: "string" },
|
|
45
|
+
url: { type: "string" },
|
|
46
|
+
"update-baseline": { type: "boolean" },
|
|
47
|
+
"no-baseline": { type: "boolean" },
|
|
48
|
+
posture: { type: "string" },
|
|
49
|
+
session: { type: "string" },
|
|
50
|
+
client: { type: "string" },
|
|
51
|
+
only: { type: "string" },
|
|
52
|
+
quiet: { type: "boolean" },
|
|
53
|
+
yes: { type: "boolean" },
|
|
54
|
+
"confirm-contract": { type: "boolean" },
|
|
55
|
+
"no-agent": { type: "boolean" },
|
|
56
|
+
paths: { type: "string" },
|
|
57
|
+
contract: { type: "string" },
|
|
58
|
+
ci: { type: "boolean" },
|
|
59
|
+
"no-ci": { type: "boolean" },
|
|
60
|
+
"no-rules": { type: "boolean" },
|
|
61
|
+
"no-publish": { type: "boolean" }
|
|
62
|
+
};
|
|
63
|
+
export function parse(argv) {
|
|
64
|
+
try {
|
|
65
|
+
const { values, positionals } = parseArgs({
|
|
66
|
+
args: argv,
|
|
67
|
+
options: OPTIONS,
|
|
68
|
+
allowPositionals: true,
|
|
69
|
+
strict: true
|
|
70
|
+
});
|
|
71
|
+
return { values, positionals };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new CliError(error.message.replace(/\. To specify.*$/, ""));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=args.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const BASELINE_FILE = "invariant.baseline.json";
|
|
4
|
+
const baselineSchema = z.strictObject({
|
|
5
|
+
version: z.literal(1),
|
|
6
|
+
/** Violations that existed when the baseline was taken. They are hidden, not fixed, and counted. */
|
|
7
|
+
violations: z.array(z.strictObject({
|
|
8
|
+
file: z.string(),
|
|
9
|
+
code: z.string(),
|
|
10
|
+
message: z.string(),
|
|
11
|
+
count: z.number().int().positive()
|
|
12
|
+
}))
|
|
13
|
+
});
|
|
14
|
+
/** Line numbers are left out so moving code does not bring an old finding back as new. */
|
|
15
|
+
const keyOf = (v) => `${v.file}\u0000${v.code}\u0000${v.message}`;
|
|
16
|
+
export function makeBaseline(violations) {
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
for (const v of violations) {
|
|
19
|
+
const k = keyOf(v);
|
|
20
|
+
const held = counts.get(k);
|
|
21
|
+
if (held)
|
|
22
|
+
held.count++;
|
|
23
|
+
else
|
|
24
|
+
counts.set(k, { file: v.file, code: v.code, message: v.message, count: 1 });
|
|
25
|
+
}
|
|
26
|
+
const list = [...counts.values()].sort((a, b) => a.file !== b.file
|
|
27
|
+
? a.file < b.file
|
|
28
|
+
? -1
|
|
29
|
+
: 1
|
|
30
|
+
: a.code !== b.code
|
|
31
|
+
? a.code < b.code
|
|
32
|
+
? -1
|
|
33
|
+
: 1
|
|
34
|
+
: a.message < b.message
|
|
35
|
+
? -1
|
|
36
|
+
: 1);
|
|
37
|
+
return { version: 1, violations: list };
|
|
38
|
+
}
|
|
39
|
+
export function readBaseline(file) {
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return undefined;
|
|
42
|
+
const parsed = baselineSchema.safeParse(JSON.parse(readFileSync(file, "utf8")));
|
|
43
|
+
if (!parsed.success)
|
|
44
|
+
throw new Error(`${file} is not a valid baseline. Regenerate it with \`invariant validate <path...> --update-baseline\`.`);
|
|
45
|
+
return parsed.data;
|
|
46
|
+
}
|
|
47
|
+
export const writeBaseline = (file, baseline) => writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
48
|
+
/** A violation is hidden only while the baseline still has room for one like it. A new one beyond the count shows. */
|
|
49
|
+
export function applyBaseline(violations, baseline) {
|
|
50
|
+
const room = new Map(baseline.violations.map((v) => [keyOf(v), v.count]));
|
|
51
|
+
const fresh = [];
|
|
52
|
+
let hidden = 0;
|
|
53
|
+
for (const v of violations) {
|
|
54
|
+
const left = room.get(keyOf(v)) ?? 0;
|
|
55
|
+
if (left > 0) {
|
|
56
|
+
room.set(keyOf(v), left - 1);
|
|
57
|
+
hidden++;
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
fresh.push(v);
|
|
61
|
+
}
|
|
62
|
+
return { fresh, hidden };
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=baseline.js.map
|
package/dist/cli/cli.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parse } from "./args.js";
|
|
3
|
+
import * as commands from "./commands.js";
|
|
4
|
+
import * as browserCommands from "./commands-browser.js";
|
|
5
|
+
import * as discoverCommands from "./commands-discover.js";
|
|
6
|
+
import * as enforcementCommands from "./commands-enforcement.js";
|
|
7
|
+
import * as governance from "./commands-governance.js";
|
|
8
|
+
import * as hookCommands from "./commands-hook.js";
|
|
9
|
+
import * as patterns from "./commands-patterns.js";
|
|
10
|
+
import * as setupCommands from "./commands-setup.js";
|
|
11
|
+
import * as validation from "./commands-validate.js";
|
|
12
|
+
import { CliError } from "./errors.js";
|
|
13
|
+
import { CLI_NAME, CLI_VERSION } from "./version.js";
|
|
14
|
+
export const HELP = `${CLI_NAME} ${CLI_VERSION}: compile a design system into a machine-readable contract
|
|
15
|
+
|
|
16
|
+
usage: ${CLI_NAME} <command> [options]
|
|
17
|
+
|
|
18
|
+
init detect sources and write invariant.config.json
|
|
19
|
+
source add <type> [path] add a source (react, vue, angular, storybook, tokens)
|
|
20
|
+
source remove <id> remove a configured source
|
|
21
|
+
source list list configured sources
|
|
22
|
+
analyze extract each source and report what was found
|
|
23
|
+
compile build the contract, evidence, diagnostics, review and report [--watch recompiles when sources change]
|
|
24
|
+
components list | inspect <name>
|
|
25
|
+
tokens list
|
|
26
|
+
setup one command: compile, propose patterns from your code, connect your AI tool [--yes] [--confirm-contract] [--as name] [--paths a,b] [--only 1,3|none] [--posture gate-errors|warn|off] [--contract dir] [--no-agent] [--ci|--no-ci] [--no-rules] [--no-baseline] [--no-publish]
|
|
27
|
+
browser run check a running app in a real browser (opt-in; needs Playwright) [--spec file] [--browser chromium|firefox|webkit] [--url base]
|
|
28
|
+
discover <path...> find structures that recur in your code and write them as candidates [--min-files n] [--min-uses n] [--similarity 0-1] [--depth n]
|
|
29
|
+
candidates list | show <id> | accept|reject|defer <id> | promote <id> review candidates [--status s] [--kind k] [--all] [--as name] [--note why] [--id pattern-id]
|
|
30
|
+
patterns list | inspect <name> | resolve <name> | search <query> | relationships <name> [--category c] [--status s] [--direction out|in|both] [--depth n] [--variation v] [--evidence]
|
|
31
|
+
conflicts list [--severity s] [--type t] [--subject id]
|
|
32
|
+
validate <path...> check application code against the contract [--format text|json|github|sarif] [--fail-on error|warning|never]
|
|
33
|
+
verify check the compiled output itself [--fresh recompiles and compares]
|
|
34
|
+
rules list the validation rules
|
|
35
|
+
policy list list approved policies (written in invariant.policies.json)
|
|
36
|
+
review list | show <id> what needs a human decision
|
|
37
|
+
review approve|reject|resolve|accept|dismiss <id> --as <name> record a decision in invariant.decisions.json
|
|
38
|
+
export --to <dir> write a portable copy of the artifacts
|
|
39
|
+
mcp serve the compiled contract to AI tools over stdio (read-only)
|
|
40
|
+
hook check an agent's Write/Edit against the contract and answer in its hook format (stdin payload) [--posture gate-errors|warn|off] [--client claude-code|cursor] [--quiet]
|
|
41
|
+
enforcement report the enforcement timeline of an agent session [--session id|--all] [--format text|html] [--to file]
|
|
42
|
+
agent init <client|all> write MCP config and skills for claude-code, cursor, codex, windsurf
|
|
43
|
+
agent list list supported clients
|
|
44
|
+
|
|
45
|
+
options: --version --help --cwd <dir> --config <file> --dir <output dir> --json --force --trust-config --keep-ir --evidence
|
|
46
|
+
agent init: --command <exe> --command-arg <arg> --dry-run
|
|
47
|
+
exit codes: 0 ok, 1 findings or invalid output, 2 bad usage or input
|
|
48
|
+
`;
|
|
49
|
+
/** Runs one command line. Returns the process exit code and never throws. */
|
|
50
|
+
export async function run(argv, io) {
|
|
51
|
+
try {
|
|
52
|
+
const args = parse(argv);
|
|
53
|
+
if (args.values.version) {
|
|
54
|
+
io.stdout(`${CLI_VERSION}\n`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const [command] = args.positionals;
|
|
58
|
+
if (args.values.help || !command || command === "help") {
|
|
59
|
+
io.stdout(HELP);
|
|
60
|
+
return command || args.values.help ? 0 : 2;
|
|
61
|
+
}
|
|
62
|
+
const context = { io, args, cwd: path.resolve(io.cwd, args.values.cwd ?? ".") };
|
|
63
|
+
switch (command) {
|
|
64
|
+
case "init":
|
|
65
|
+
return await commands.init(context);
|
|
66
|
+
case "source":
|
|
67
|
+
return await commands.source(context);
|
|
68
|
+
case "analyze":
|
|
69
|
+
return await commands.analyze(context);
|
|
70
|
+
case "compile":
|
|
71
|
+
return await commands.compileCommand(context);
|
|
72
|
+
case "components":
|
|
73
|
+
return await commands.components(context);
|
|
74
|
+
case "tokens":
|
|
75
|
+
return await commands.tokens(context);
|
|
76
|
+
case "patterns":
|
|
77
|
+
return await patterns.patterns(context);
|
|
78
|
+
case "discover":
|
|
79
|
+
return await discoverCommands.discoverCommand(context);
|
|
80
|
+
case "setup":
|
|
81
|
+
return await setupCommands.setup(context);
|
|
82
|
+
case "browser":
|
|
83
|
+
return await browserCommands.browser(context);
|
|
84
|
+
case "candidates":
|
|
85
|
+
return await discoverCommands.candidatesCommand(context);
|
|
86
|
+
case "conflicts":
|
|
87
|
+
return await commands.conflicts(context);
|
|
88
|
+
case "validate":
|
|
89
|
+
return await validation.validate(context);
|
|
90
|
+
case "verify":
|
|
91
|
+
return await commands.verify(context);
|
|
92
|
+
case "rules":
|
|
93
|
+
return await validation.rules(context);
|
|
94
|
+
case "review":
|
|
95
|
+
return await governance.review(context);
|
|
96
|
+
case "policy":
|
|
97
|
+
return await governance.policy(context);
|
|
98
|
+
case "export":
|
|
99
|
+
return await commands.exportCommand(context);
|
|
100
|
+
case "mcp":
|
|
101
|
+
return await commands.mcp(context);
|
|
102
|
+
case "hook":
|
|
103
|
+
return await hookCommands.hook(context);
|
|
104
|
+
case "enforcement":
|
|
105
|
+
return await enforcementCommands.enforcement(context);
|
|
106
|
+
case "agent":
|
|
107
|
+
return await commands.agent(context);
|
|
108
|
+
default:
|
|
109
|
+
throw new CliError(`unknown command "${command}". Run \`${CLI_NAME} help\`.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof CliError) {
|
|
114
|
+
io.stderr(`error: ${error.message}\n`);
|
|
115
|
+
return error.exitCode;
|
|
116
|
+
}
|
|
117
|
+
io.stderr(`error: ${error.stack ?? String(error)}\n`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BROWSER_RESULTS_FILE, BROWSER_SPEC_FILE, BROWSERS, checkOrigins, PlaywrightMissing, parseSpec, playwrightDriver, runBrowserChecks, summarizeBrowser } from "../browser-verify/index.js";
|
|
4
|
+
import { json, outputDir, print, writableDir } from "./commands.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { table } from "./format.js";
|
|
7
|
+
import { loadCompiled } from "./store.js";
|
|
8
|
+
import { CLI_NAME } from "./version.js";
|
|
9
|
+
const USAGE = `usage: ${CLI_NAME} browser run [--spec ${BROWSER_SPEC_FILE}] [--browser chromium|firefox|webkit] [--url <base url>] [--json]
|
|
10
|
+
Runs the scenarios in the spec against a running app in a headless browser. Opt-in: it needs Playwright in your project,
|
|
11
|
+
and nothing else in ${CLI_NAME} starts a browser. Results are runtime findings, kept apart from \`validate\`.`;
|
|
12
|
+
/** `invariant browser run`: runtime checks in a real browser, separate from static validation. */
|
|
13
|
+
export async function browser(c) {
|
|
14
|
+
const [, sub] = c.args.positionals;
|
|
15
|
+
if (sub !== "run")
|
|
16
|
+
throw new CliError(USAGE);
|
|
17
|
+
const specFile = path.resolve(c.cwd, c.args.values.spec ?? BROWSER_SPEC_FILE);
|
|
18
|
+
if (!existsSync(specFile))
|
|
19
|
+
throw new CliError(`no ${path.basename(specFile)}. Write one (see docs/specifications/browser-verification-v1.md), or pass --spec.`);
|
|
20
|
+
const parsed = parseSpec(readFileSync(specFile, "utf8"), path.basename(specFile));
|
|
21
|
+
if (!parsed.ok)
|
|
22
|
+
throw new CliError(parsed.message);
|
|
23
|
+
const spec = c.args.values.url ? { ...parsed.value, baseUrl: c.args.values.url } : parsed.value;
|
|
24
|
+
if (c.args.values.url) {
|
|
25
|
+
try {
|
|
26
|
+
new URL(c.args.values.url);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new CliError("--url must be a full URL such as http://localhost:3000");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const refused = checkOrigins(spec);
|
|
33
|
+
if (refused)
|
|
34
|
+
throw new CliError(refused);
|
|
35
|
+
const name = (c.args.values.browser ?? spec.browser ?? "chromium");
|
|
36
|
+
if (!BROWSERS.includes(name))
|
|
37
|
+
throw new CliError(`--browser must be one of: ${BROWSERS.join(", ")}`);
|
|
38
|
+
const notes = [];
|
|
39
|
+
try {
|
|
40
|
+
const { contract } = loadCompiled(await outputDir(c));
|
|
41
|
+
const patterns = new Map(contract.patterns.map((p) => [p.id, p]));
|
|
42
|
+
const constraints = new Set(contract.constraints.map((x) => x.id));
|
|
43
|
+
for (const s of spec.scenarios) {
|
|
44
|
+
if (s.pattern && !patterns.has(s.pattern))
|
|
45
|
+
notes.push(`${s.id}: pattern ${s.pattern} is not in the contract`);
|
|
46
|
+
if (s.constraint && !constraints.has(s.constraint))
|
|
47
|
+
notes.push(`${s.id}: constraint ${s.constraint} is not in the contract`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// No compiled contract is fine: scenarios are checked without linking them to patterns.
|
|
52
|
+
}
|
|
53
|
+
let driver;
|
|
54
|
+
try {
|
|
55
|
+
driver = await (c.io.browserDriver ?? playwrightDriver)(name, c.cwd);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof PlaywrightMissing)
|
|
59
|
+
throw new CliError(error.message);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
let results;
|
|
63
|
+
try {
|
|
64
|
+
results = await runBrowserChecks(driver, spec);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await driver.close().catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
const summary = summarizeBrowser(results);
|
|
70
|
+
const report = {
|
|
71
|
+
version: 1,
|
|
72
|
+
kind: "runtime",
|
|
73
|
+
spec: path.basename(specFile),
|
|
74
|
+
browser: { name, version: driver.version() },
|
|
75
|
+
summary,
|
|
76
|
+
notes,
|
|
77
|
+
results
|
|
78
|
+
};
|
|
79
|
+
const dir = await writableDir(c);
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
writeFileSync(path.join(dir, BROWSER_RESULTS_FILE), `${JSON.stringify(report, null, 2)}\n`);
|
|
82
|
+
if (c.args.values.json)
|
|
83
|
+
json(c, report);
|
|
84
|
+
else {
|
|
85
|
+
print(c, `${name} ${driver.version()}: ${summary.pass} passed, ${summary.fail} failed, ${summary.error} could not run (runtime results; static results are from \`validate\`)`);
|
|
86
|
+
for (const n of notes)
|
|
87
|
+
print(c, `note: ${n}`);
|
|
88
|
+
const bad = results.filter((r) => r.status !== "pass");
|
|
89
|
+
if (bad.length > 0)
|
|
90
|
+
print(c, `\n${table(["scenario", "check", "status", "viewport", "why"], bad.map((r) => [
|
|
91
|
+
r.scenario,
|
|
92
|
+
r.check,
|
|
93
|
+
r.status,
|
|
94
|
+
`${r.context.viewport.width}x${r.context.viewport.height}`,
|
|
95
|
+
r.reason
|
|
96
|
+
]))}`);
|
|
97
|
+
for (const r of bad)
|
|
98
|
+
if (r.suggestion)
|
|
99
|
+
print(c, ` ${r.scenario} ${r.check}: ${r.suggestion}`);
|
|
100
|
+
print(c, `\nwritten to ${BROWSER_RESULTS_FILE}`);
|
|
101
|
+
}
|
|
102
|
+
return summary.fail + summary.error > 0 ? 1 : 0;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=commands-browser.js.map
|