@invariant-ui/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,17 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/mcarlucci/invariant/main/site/assets/logo-wordmark.svg">
4
- <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/mcarlucci/invariant/main/site/assets/logo-wordmark-light.svg">
5
- <img alt="Invariant" src="https://raw.githubusercontent.com/mcarlucci/invariant/main/site/assets/logo-wordmark-light.svg" width="280">
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/invariant-ui/invariant/main/site/assets/logo-wordmark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/invariant-ui/invariant/main/site/assets/logo-wordmark-light.svg">
5
+ <img alt="Invariant" src="https://raw.githubusercontent.com/invariant-ui/invariant/main/site/assets/logo-wordmark-light.svg" width="280">
6
6
  </picture>
7
7
  </p>
8
8
 
9
9
  <p align="center"><strong>Turn your design system into a contract that AI agents can read and check.</strong></p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://mcarlucci.github.io/invariant/">Website</a> &middot;
13
- <a href="https://mcarlucci.github.io/invariant/docs/">Documentation</a> &middot;
14
- <a href="https://github.com/mcarlucci/invariant/blob/main/docs/specifications">Specifications</a>
12
+ <a href="https://invariant-ui.com/">Website</a> &middot;
13
+ <a href="https://invariant-ui.com/docs/">Documentation</a> &middot;
14
+ <a href="https://github.com/invariant-ui/invariant/blob/main/docs/specifications">Specifications</a>
15
15
  </p>
16
16
 
17
17
  Invariant scans your existing design system (React, Vue, Angular, Storybook, design tokens) and compiles it into one machine-readable file: the **contract**. AI coding tools then use that contract to learn which components, props and tokens really exist, and to check that generated code follows them.
@@ -157,6 +157,8 @@ invariant agent init claude-code # or cursor, codex, windsurf, all
157
157
 
158
158
  With Claude Code, you watch the contract being enforced. Ask an agent for a settings page with a delete button and every file it writes is checked before it lands:
159
159
 
160
+ ![An agent is blocked three times, then passes: the enforcement loop in the terminal](https://github.com/invariant-ui/invariant/blob/main/docs/demo/enforcement-loop.gif)
161
+
160
162
  ```text
161
163
  1. invariant blocked app/pages/Settings.tsx: 1 error against the @acme/ui contract
162
164
  ✗ prop-value-not-allowed: Button.variant is "danger", which is not one of "primary", "secondary", "ghost" (line 8)
@@ -166,7 +168,7 @@ With Claude Code, you watch the contract being enforced. Ask an agent for a sett
166
168
  4. invariant ✓ app/pages/Settings.tsx follows the @acme/ui contract (6 component uses checked)
167
169
  ```
168
170
 
169
- Run `pnpm demo` in a checkout to replay exactly that, and `invariant enforcement report` (add `--format html --to report.html`) to get a forwardable timeline: `4 writes checked · 3 violations caught · 3 fixed · 0 outstanding`. See [See it working in your agent chat](https://mcarlucci.github.io/invariant/docs/#enforcement).
171
+ Run `pnpm demo` in a checkout to replay exactly that, and `invariant enforcement report` (add `--format html --to report.html`) to get a forwardable timeline: `4 writes checked · 3 violations caught · 3 fixed · 0 outstanding`. See [See it working in your agent chat](https://invariant-ui.com/docs/#enforcement).
170
172
 
171
173
  `agent init` installs the hooks for Claude Code: a write that would introduce an error-severity violation is blocked with the exact finding and fix; warnings show up right after the write; clean passes get a green line in the chat. The hooks fail open (if invariant cannot run, the write proceeds) and respect your baseline and rule overrides. Set `enforcement.hooks` to `warn` or `off` in `invariant.config.json` to soften or disable this.
172
174
 
@@ -223,17 +225,17 @@ pnpm update:golden # regenerate golden fixtures
223
225
 
224
226
  ## Documentation
225
227
 
226
- - [Website](https://mcarlucci.github.io/invariant/) and [documentation site](https://mcarlucci.github.io/invariant/docs/)
227
- - [System architecture](https://github.com/mcarlucci/invariant/blob/main/docs/architecture/system-architecture.md)
228
- - [Pattern definitions](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/pattern-definitions-v1.md): write layouts, interactions and page archetypes in `invariant.patterns.json`
229
- - [Browser verification](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/browser-verification-v1.md): opt-in runtime checks for focus, keyboard order, rendered composition and responsive layout
230
- - [Pattern discovery](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/pattern-discovery-v1.md): find recurring structures in your code and review them as candidates
231
- - [ADR 0001: Foundational architecture](https://github.com/mcarlucci/invariant/blob/main/docs/decisions/0001-architecture.md)
232
- - Specifications in [`docs/specifications/`](https://github.com/mcarlucci/invariant/blob/main/docs/specifications): [CLI](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/cli-v1.md), [contract](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/contract-v1.md), [MCP and agents](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/mcp-and-agents-v1.md), [validation and governance](https://github.com/mcarlucci/invariant/blob/main/docs/specifications/validation-and-governance-v1.md), and more
228
+ - [Website](https://invariant-ui.com/) and [documentation site](https://invariant-ui.com/docs/)
229
+ - [System architecture](https://github.com/invariant-ui/invariant/blob/main/docs/architecture/system-architecture.md)
230
+ - [Pattern definitions](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/pattern-definitions-v1.md): write layouts, interactions and page archetypes in `invariant.patterns.json`
231
+ - [Browser verification](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/browser-verification-v1.md): opt-in runtime checks for focus, keyboard order, rendered composition and responsive layout
232
+ - [Pattern discovery](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/pattern-discovery-v1.md): find recurring structures in your code and review them as candidates
233
+ - [ADR 0001: Foundational architecture](https://github.com/invariant-ui/invariant/blob/main/docs/decisions/0001-architecture.md)
234
+ - Specifications in [`docs/specifications/`](https://github.com/invariant-ui/invariant/blob/main/docs/specifications): [CLI](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/cli-v1.md), [contract](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/contract-v1.md), [MCP and agents](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/mcp-and-agents-v1.md), [validation and governance](https://github.com/invariant-ui/invariant/blob/main/docs/specifications/validation-and-governance-v1.md), and more
233
235
 
234
236
  ## License
235
237
 
236
- Invariant is source-available under the [Functional Source License, Version 1.1, ALv2 Future License](https://github.com/mcarlucci/invariant/blob/main/LICENSE) (FSL-1.1-ALv2).
238
+ Invariant is source-available under the [Functional Source License, Version 1.1, ALv2 Future License](https://github.com/invariant-ui/invariant/blob/main/LICENSE) (FSL-1.1-ALv2).
237
239
 
238
240
  You can read, run, modify and redistribute it for any purpose except a Competing Use: offering it in a commercial product or service that substitutes for Invariant or offers the same or similar functionality. Internal use, non-commercial education and research, and professional services for a licensee are always allowed.
239
241
 
@@ -44,10 +44,10 @@ Also ask which folders hold the applications that use it, if that is not obvious
44
44
 
45
45
  ## 3. Compile and look
46
46
  Run \`invariant setup\` with the choices from step 2. Run like this, without a terminal, it compiles and prints a plan, and changes nothing else.
47
- Summarize in plain words, using only the output: how many components and what kinds of tokens were found, which frameworks, any errors or warnings that matter, and anything it said it could not extract. If compile failed, explain why and stop.
47
+ Summarize in plain words, using only the output: how many components were found, how many tokens (say plainly if there are none, since then colors and spacing cannot be checked), which frameworks, how many conflicts between sources (for example Storybook claiming a value the code does not have), any errors or warnings that matter, and anything it said it could not extract. If compile failed, explain why and stop.
48
48
 
49
49
  ## 4. Does this match?
50
- Ask: "Does this match your design system? Is anything missing or wrong?" If something is off, adjust (\`invariant source add <type> [path]\` or \`invariant source remove <id>\`, or a different \`--paths\`), run \`invariant compile\`, and show the new summary. Repeat until they say it matches. Do not go on before that.
50
+ Ask: "Does this match your design system? Is anything missing or wrong?" If there are conflicts or no tokens, say so in the question and ask whether to go on anyway: conflicts stay unresolved and are listed in \`invariant/invariant.review.json\` for a person to decide. If something is off, adjust (\`invariant source add <type> [path]\` or \`invariant source remove <id>\`, or a different \`--paths\`), run \`invariant compile\`, and show the new summary. Repeat until they say it matches. Do not go on before that.
51
51
 
52
52
  ## 5. Patterns
53
53
  If the plan lists recurring structures found in their application code, say each one in a plain sentence and explain that approved patterns guide AI tools and unapproved ones are only proposals. They are numbered in the plan. Ask: approve all, approve some, or none for now. For some, ask which numbers. Then ask for the approver's name.
@@ -63,13 +63,13 @@ Summarize every decision in a short list and ask for a final yes. Then run, once
63
63
 
64
64
  invariant setup --confirm-contract --as "<their name>" --no-agent --posture <choice> [--only <numbers>|none] [--no-baseline] [--no-ci] [--paths ...] [--contract ...]
65
65
 
66
- \`--no-agent\` is always passed here: the plugin already connects the AI tool, so setup must not add a second connection. Report what it did, using its output.
66
+ \`--no-agent\` is always passed here: the plugin already connects the AI tool, so setup must not add a second connection. Report what it did, using its output. Setup can also add a \`prepack\` script to \`package.json\` (so a published design system carries its contract), write \`invariant.candidate-reviews.json\`, and turn on a few starter rules as warnings; say so if it did.
67
67
 
68
68
  ## 8. Prove it works
69
69
  Pick a component from the contract (\`get_component\`) with a property that accepts a fixed set of values. Try to write a small file that uses a value outside that set. If blocking was chosen, the write is refused: show the person the message, and nothing is left behind. If the write succeeded, say so plainly, delete the file, and explain the check did not run (Claude Code may need a restart before the checks start in a project that had no configuration when the session began).
70
70
 
71
71
  ## 9. Wrap up
72
- Tell the person, in a short list: what was approved and by whom, what is still only a proposal, what was skipped or could not be extracted, and what changes from now on (every file an AI tool writes is checked, and a mistake is shown with the exact fix). Name the files to commit so teammates get the same contract: \`invariant.config.json\`, the \`invariant/\` folder, \`invariant.patterns.json\`, \`invariant.decisions.json\` and \`invariant.policies.json\` when present, \`invariant.baseline.json\`, and the CI workflow. Mention \`invariant enforcement report\` for a record of what was caught, and that teammates only need to install this plugin.
72
+ Tell the person, in a short list: what was approved and by whom, what is still only a proposal, what was skipped or could not be extracted, and what changes from now on (every file an AI tool writes is checked, and a mistake is shown with the exact fix). Run \`git status\` and list the files Invariant added or changed, so they can be committed and teammates get the same contract (never the \`.invariant/\` folder, which ignores itself). Mention \`invariant enforcement report\` for a record of what was caught, and that teammates only need to install this plugin.
73
73
  `;
74
74
  const COMMENT = `${MARKER}; part of the invariant plugin`;
75
75
  /** The setup skill and the generic copies of the build, discovery, layout, review and refactor skills. */
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "invariant";
2
- export const CLI_VERSION = "0.1.0";
2
+ export const CLI_VERSION = "0.2.0"; // x-release-please-version
3
3
  /** The name this is published under on npm (`invariant` belongs to an unrelated package). */
4
4
  export const NPM_PACKAGE = "@invariant-ui/cli";
5
5
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invariant-ui/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Compile your design system, UX patterns and page layouts into a contract AI agents can read, and check code against it.",
5
5
  "keywords": [
6
6
  "design-system",
@@ -12,13 +12,13 @@
12
12
  "ux-patterns"
13
13
  ],
14
14
  "license": "SEE LICENSE IN LICENSE",
15
- "homepage": "https://mcarlucci.github.io/invariant/",
15
+ "homepage": "https://invariant-ui.com/",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+https://github.com/mcarlucci/invariant.git"
18
+ "url": "git+https://github.com/invariant-ui/invariant.git"
19
19
  },
20
20
  "bugs": {
21
- "url": "https://github.com/mcarlucci/invariant/issues"
21
+ "url": "https://github.com/invariant-ui/invariant/issues"
22
22
  },
23
23
  "type": "module",
24
24
  "bin": {
@@ -1,105 +0,0 @@
1
- import ts from "typescript";
2
- export function unwrap(node) {
3
- let current = node;
4
- while (ts.isParenthesizedExpression(current) ||
5
- ts.isAsExpression(current) ||
6
- ts.isSatisfiesExpression(current) ||
7
- ts.isNonNullExpression(current) ||
8
- ts.isTypeAssertionExpression(current)) {
9
- current = current.expression;
10
- }
11
- return current;
12
- }
13
- export function collapse(text) {
14
- return text.replace(/\s+/g, " ").trim();
15
- }
16
- /** JSON value of a static literal expression, or undefined when the expression is not a plain literal. */
17
- export function literalValue(expression) {
18
- const node = unwrap(expression);
19
- if (ts.isStringLiteralLike(node))
20
- return node.text;
21
- if (ts.isNumericLiteral(node))
22
- return Number(node.text);
23
- if (node.kind === ts.SyntaxKind.TrueKeyword)
24
- return true;
25
- if (node.kind === ts.SyntaxKind.FalseKeyword)
26
- return false;
27
- if (node.kind === ts.SyntaxKind.NullKeyword)
28
- return null;
29
- if (ts.isPrefixUnaryExpression(node) &&
30
- node.operator === ts.SyntaxKind.MinusToken &&
31
- ts.isNumericLiteral(node.operand)) {
32
- return -Number(node.operand.text);
33
- }
34
- if (ts.isArrayLiteralExpression(node)) {
35
- const items = node.elements.map((e) => literalValue(e));
36
- return items.every((i) => i !== undefined) ? items : undefined;
37
- }
38
- if (ts.isObjectLiteralExpression(node)) {
39
- const out = {};
40
- for (const p of node.properties) {
41
- if (!ts.isPropertyAssignment(p) || !(ts.isIdentifier(p.name) || ts.isStringLiteralLike(p.name)))
42
- return undefined;
43
- const v = literalValue(p.initializer);
44
- if (v === undefined)
45
- return undefined;
46
- out[p.name.text] = v;
47
- }
48
- return out;
49
- }
50
- return undefined;
51
- }
52
- export function lineOf(node) {
53
- const sf = node.getSourceFile();
54
- return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
55
- }
56
- /** First source line of a node, trimmed and bounded, for use as an evidence excerpt. */
57
- export function excerpt(node) {
58
- const text = node.getText(node.getSourceFile());
59
- return collapse(text.split("\n")[0] ?? "").slice(0, 200);
60
- }
61
- export function relativeFileOf(analysis, node) {
62
- return analysis.relativePath(node.getSourceFile().fileName);
63
- }
64
- export function isProjectNode(analysis, node) {
65
- return relativeFileOf(analysis, node) !== undefined;
66
- }
67
- export function hasModifier(node, kind) {
68
- return ts.canHaveModifiers(node) && !!ts.getModifiers(node)?.some((m) => m.kind === kind);
69
- }
70
- /** Resolves aliases (imports and re-exports) to the symbol that declares the value. */
71
- export function resolveSymbol(checker, symbol) {
72
- if (!symbol)
73
- return undefined;
74
- return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
75
- }
76
- export function declarationOf(symbol) {
77
- return symbol?.valueDeclaration ?? symbol?.declarations?.[0];
78
- }
79
- /** `Foo.bar = value` assignments at the top level of a file, keyed `Foo.bar`. */
80
- export function topLevelAssignments(sf) {
81
- const out = new Map();
82
- for (const statement of sf.statements) {
83
- if (!ts.isExpressionStatement(statement))
84
- continue;
85
- const e = statement.expression;
86
- if (ts.isBinaryExpression(e) &&
87
- e.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
88
- ts.isPropertyAccessExpression(e.left) &&
89
- ts.isIdentifier(e.left.expression)) {
90
- out.set(`${e.left.expression.text}.${e.left.name.text}`, e.right);
91
- }
92
- }
93
- return out;
94
- }
95
- /** Walks `node` without descending into nested function-like bodies (other than `node` itself). */
96
- export function walkSameFunction(node, visit) {
97
- const go = (n) => {
98
- if (n !== node && ts.isFunctionLike(n))
99
- return;
100
- visit(n);
101
- ts.forEachChild(n, go);
102
- };
103
- ts.forEachChild(node, go);
104
- }
105
- //# sourceMappingURL=ast.js.map
@@ -1,58 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import ts from "typescript";
3
- /**
4
- * Builds a type-checking program over the supplied project files only. Nothing is read from
5
- * `node_modules` and no customer code runs: third-party types (including `@types/react`) are not
6
- * loaded, so references to them stay unresolved and are reported as such rather than guessed.
7
- * TypeScript's own `lib.es2022.d.ts` is read so built-in utility types like `Omit` resolve.
8
- */
9
- export function createAnalysis(files) {
10
- const virtual = new Map([...files].map(([rel, text]) => [`/${rel}`, text]));
11
- const options = {
12
- target: ts.ScriptTarget.ES2022,
13
- module: ts.ModuleKind.ESNext,
14
- moduleResolution: ts.ModuleResolutionKind.Bundler,
15
- jsx: ts.JsxEmit.ReactJSX,
16
- lib: ["lib.es2022.d.ts"],
17
- types: [],
18
- strict: true,
19
- allowJs: true,
20
- checkJs: false,
21
- noEmit: true,
22
- skipLibCheck: true,
23
- resolveJsonModule: true,
24
- allowArbitraryExtensions: true
25
- };
26
- const libDir = ts.getDefaultLibFilePath(options).replace(/[^/\\]+$/, "");
27
- const isLib = (file) => file.startsWith(libDir);
28
- const read = (file) => {
29
- const text = virtual.get(file);
30
- if (text !== undefined)
31
- return text;
32
- return isLib(file) && existsSync(file) ? readFileSync(file, "utf8") : undefined;
33
- };
34
- const host = {
35
- getSourceFile: (file, languageVersion) => {
36
- const text = read(file);
37
- return text === undefined ? undefined : ts.createSourceFile(file, text, languageVersion, true);
38
- },
39
- getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
40
- writeFile: () => { },
41
- getCurrentDirectory: () => "/",
42
- getDirectories: () => [],
43
- fileExists: (file) => read(file) !== undefined,
44
- readFile: read,
45
- getCanonicalFileName: (file) => file,
46
- useCaseSensitiveFileNames: () => true,
47
- getNewLine: () => "\n"
48
- };
49
- const roots = [...virtual.keys()].filter((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
50
- const program = ts.createProgram({ rootNames: roots, options, host });
51
- return {
52
- program,
53
- checker: program.getTypeChecker(),
54
- relativePath: (fileName) => (virtual.has(fileName) ? fileName.slice(1) : undefined),
55
- sourceFile: (rel) => program.getSourceFile(`/${rel}`)
56
- };
57
- }
58
- //# sourceMappingURL=program.js.map
@@ -1,145 +0,0 @@
1
- import { declarationOf, resolveSymbol, unwrap } from "../compiler-core/index.js";
2
- import ts from "typescript";
3
- const MAX_DEPTH = 8;
4
- const SPY_CALLEES = new Set(["fn", "action"]);
5
- export function evaluate(checker, expression, depth = 0) {
6
- const node = unwrap(expression);
7
- if (depth > MAX_DEPTH)
8
- return dynamic();
9
- if (ts.isStringLiteralLike(node))
10
- return resolved(node.text);
11
- if (ts.isNumericLiteral(node))
12
- return resolved(Number(node.text));
13
- if (node.kind === ts.SyntaxKind.TrueKeyword)
14
- return resolved(true);
15
- if (node.kind === ts.SyntaxKind.FalseKeyword)
16
- return resolved(false);
17
- if (node.kind === ts.SyntaxKind.NullKeyword)
18
- return resolved(null);
19
- if (ts.isIdentifier(node) && node.text === "undefined")
20
- return { unresolved: [], spies: [] };
21
- if (ts.isPrefixUnaryExpression(node) &&
22
- node.operator === ts.SyntaxKind.MinusToken &&
23
- ts.isNumericLiteral(node.operand)) {
24
- return resolved(-Number(node.operand.text));
25
- }
26
- if (ts.isObjectLiteralExpression(node))
27
- return evaluateObject(checker, node, depth);
28
- if (ts.isArrayLiteralExpression(node)) {
29
- const items = [];
30
- const unresolved = [];
31
- for (const element of node.elements) {
32
- const inner = evaluate(checker, element, depth + 1);
33
- if (inner.value === undefined || inner.unresolved.length > 0)
34
- unresolved.push("[]");
35
- else
36
- items.push(inner.value);
37
- }
38
- // An array with any unresolvable element is not partially trusted.
39
- return unresolved.length > 0 ? dynamic() : resolved(items);
40
- }
41
- if (ts.isIdentifier(node)) {
42
- const decl = declarationOf(resolveSymbol(checker, checker.getSymbolAtLocation(node)));
43
- if (decl && ts.isVariableDeclaration(decl) && decl.initializer && decl !== node.parent)
44
- return evaluate(checker, decl.initializer, depth + 1);
45
- return dynamic();
46
- }
47
- if (ts.isPropertyAccessExpression(node) ||
48
- (ts.isElementAccessExpression(node) &&
49
- node.argumentExpression &&
50
- ts.isStringLiteralLike(node.argumentExpression))) {
51
- const key = ts.isPropertyAccessExpression(node)
52
- ? node.name.text
53
- : node.argumentExpression.text;
54
- const base = evaluate(checker, node.expression, depth + 1);
55
- if (base.value !== null &&
56
- typeof base.value === "object" &&
57
- !Array.isArray(base.value) &&
58
- key in base.value) {
59
- const picked = base.value[key];
60
- return {
61
- ...(picked === undefined ? {} : { value: picked }),
62
- unresolved: base.unresolved
63
- .filter((p) => p === key || p.startsWith(`${key}.`))
64
- .map((p) => (p === key ? "" : p.slice(key.length + 1))),
65
- spies: base.spies
66
- .filter((p) => p === key || p.startsWith(`${key}.`))
67
- .map((p) => (p === key ? "" : p.slice(key.length + 1)))
68
- };
69
- }
70
- return dynamic();
71
- }
72
- if (ts.isCallExpression(node) && SPY_CALLEES.has(node.expression.getText().replace(/^.*\./, ""))) {
73
- return { unresolved: [""], spies: [""] };
74
- }
75
- return dynamic();
76
- }
77
- function evaluateObject(checker, node, depth) {
78
- const value = {};
79
- const unresolved = [];
80
- const spies = [];
81
- const keys = new Set();
82
- const mark = (key, inner) => {
83
- for (const p of inner.unresolved)
84
- unresolved.push(p === "" ? key : `${key}.${p}`);
85
- for (const p of inner.spies)
86
- spies.push(p === "" ? key : `${key}.${p}`);
87
- };
88
- for (const property of node.properties) {
89
- if (ts.isSpreadAssignment(property)) {
90
- const inner = evaluate(checker, property.expression, depth + 1);
91
- if (inner.value !== null && typeof inner.value === "object" && !Array.isArray(inner.value)) {
92
- for (const [k, v] of Object.entries(inner.value))
93
- value[k] = v;
94
- for (const k of inner.keys ?? Object.keys(inner.value))
95
- keys.add(k);
96
- unresolved.push(...inner.unresolved);
97
- spies.push(...inner.spies);
98
- }
99
- else
100
- unresolved.push("...");
101
- continue;
102
- }
103
- const name = propertyName(property);
104
- if (name === undefined) {
105
- unresolved.push("<computed>");
106
- continue;
107
- }
108
- keys.add(name);
109
- if (ts.isPropertyAssignment(property)) {
110
- const inner = evaluate(checker, property.initializer, depth + 1);
111
- if (inner.value !== undefined)
112
- value[name] = inner.value;
113
- mark(name, inner);
114
- }
115
- else if (ts.isShorthandPropertyAssignment(property)) {
116
- const target = declarationOf(resolveSymbol(checker, checker.getShorthandAssignmentValueSymbol(property)));
117
- const inner = target && ts.isVariableDeclaration(target) && target.initializer
118
- ? evaluate(checker, target.initializer, depth + 1)
119
- : dynamic();
120
- if (inner.value !== undefined)
121
- value[name] = inner.value;
122
- mark(name, inner);
123
- }
124
- else {
125
- // Methods and accessors: present, but not statically evaluable.
126
- unresolved.push(name);
127
- }
128
- }
129
- return { value, unresolved, spies, keys: [...keys].sort() };
130
- }
131
- function propertyName(property) {
132
- const name = property.name;
133
- if (!name)
134
- return undefined;
135
- if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name))
136
- return name.text;
137
- return undefined;
138
- }
139
- function resolved(value) {
140
- return { value, unresolved: [], spies: [] };
141
- }
142
- function dynamic() {
143
- return { unresolved: [""], spies: [] };
144
- }
145
- //# sourceMappingURL=static-eval.js.map
@@ -1,164 +0,0 @@
1
- import ts from "typescript";
2
- const dyn = (text) => ({ expression: text.replace(/\s+/g, " ").trim().slice(0, 80) });
3
- function jsxValue(init, sf) {
4
- if (!init)
5
- return true;
6
- if (ts.isStringLiteral(init))
7
- return init.text;
8
- if (ts.isJsxExpression(init) && init.expression) {
9
- const e = init.expression;
10
- if (ts.isStringLiteralLike(e))
11
- return e.text;
12
- if (ts.isNumericLiteral(e))
13
- return Number(e.text);
14
- if (e.kind === ts.SyntaxKind.TrueKeyword)
15
- return true;
16
- if (e.kind === ts.SyntaxKind.FalseKeyword)
17
- return false;
18
- if (e.kind === ts.SyntaxKind.NullKeyword)
19
- return null;
20
- return dyn(e.getText(sf));
21
- }
22
- return dyn(init.getText(sf));
23
- }
24
- /** JSX elements in a TypeScript/JavaScript snippet, by syntax only. Nothing is evaluated. */
25
- export function scanJsx(code, filename = "snippet.tsx") {
26
- const kind = /\.jsx?$/i.test(filename) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
27
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.Latest, true, kind);
28
- const uses = [];
29
- const visit = (node) => {
30
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
31
- const opening = ts.isJsxElement(node) ? node.openingElement : node;
32
- const attrs = {};
33
- let spread = false;
34
- for (const a of opening.attributes.properties) {
35
- if (ts.isJsxSpreadAttribute(a))
36
- spread = true;
37
- else
38
- attrs[a.name.getText(sf)] = jsxValue(a.initializer, sf);
39
- }
40
- const hasChildren = ts.isJsxElement(node)
41
- ? node.children.some((c) => !(ts.isJsxText(c) && c.containsOnlyTriviaWhiteSpaces))
42
- : false;
43
- uses.push({
44
- tag: opening.tagName.getText(sf),
45
- line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
46
- attrs,
47
- hasChildren,
48
- hasSpread: spread
49
- });
50
- }
51
- ts.forEachChild(node, visit);
52
- };
53
- visit(sf);
54
- return uses;
55
- }
56
- const RAW_TEXT = new Set(["script", "style"]);
57
- const camel = (n) => /^(data|aria)-/.test(n) ? n : n.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
58
- /**
59
- * Element start tags in an HTML-like template (Vue, Angular, HTML). A single linear pass: no DOM, no
60
- * expression evaluation. Event handlers, structural directives and references are skipped; bindings are
61
- * kept as dynamic values.
62
- */
63
- export function scanMarkup(code) {
64
- const uses = [];
65
- let i = 0;
66
- let line = 1;
67
- const n = code.length;
68
- const advance = (to) => {
69
- for (; i < to && i < n; i++)
70
- if (code[i] === "\n")
71
- line++;
72
- };
73
- while (i < n) {
74
- const lt = code.indexOf("<", i);
75
- if (lt < 0)
76
- break;
77
- advance(lt);
78
- if (code.startsWith("<!--", i)) {
79
- const end = code.indexOf("-->", i + 4);
80
- advance(end < 0 ? n : end + 3);
81
- continue;
82
- }
83
- const m = /^<([A-Za-z][A-Za-z0-9_.:-]*)/.exec(code.slice(i, i + 130));
84
- if (!m) {
85
- advance(i + 1);
86
- continue;
87
- }
88
- const tag = m[1];
89
- const startLine = line;
90
- let j = i + m[0].length;
91
- const attrs = {};
92
- let spread = false;
93
- let selfClosing = false;
94
- while (j < n) {
95
- while (j < n && /\s/.test(code[j]))
96
- j++;
97
- const c = code[j];
98
- if (c === undefined)
99
- break;
100
- if (c === ">") {
101
- j++;
102
- break;
103
- }
104
- if (c === "/" && code[j + 1] === ">") {
105
- selfClosing = true;
106
- j += 2;
107
- break;
108
- }
109
- const nm = /^[^\s=>/"']+/.exec(code.slice(j, j + 200));
110
- if (!nm) {
111
- j++;
112
- continue;
113
- }
114
- const raw = nm[0];
115
- j += raw.length;
116
- let value;
117
- let k = j;
118
- while (k < n && /\s/.test(code[k]))
119
- k++;
120
- if (code[k] === "=") {
121
- k++;
122
- while (k < n && /\s/.test(code[k]))
123
- k++;
124
- const q = code[k];
125
- if (q === '"' || q === "'") {
126
- const end = code.indexOf(q, k + 1);
127
- value = code.slice(k + 1, end < 0 ? n : end);
128
- j = end < 0 ? n : end + 1;
129
- }
130
- else {
131
- const um = /^[^\s>]+/.exec(code.slice(k, k + 200));
132
- value = um?.[0] ?? "";
133
- j = k + value.length;
134
- }
135
- }
136
- if (/^(@|\(|v-on:|v-on$|#|\*|v-if|v-else|v-for|v-show|v-model|v-slot|ng)/.test(raw) &&
137
- !/^\[/.test(raw)) {
138
- if (raw === "v-bind" || raw.startsWith("v-bind="))
139
- spread = true;
140
- continue;
141
- }
142
- const bound = /^(:|v-bind:|\[)/.test(raw);
143
- const name = camel(raw
144
- .replace(/^(:|v-bind:)/, "")
145
- .replace(/^\[|\]$/g, "")
146
- .replace(/^attr\./, ""));
147
- attrs[name] = bound ? dyn(value ?? "") : value === undefined ? true : value;
148
- }
149
- uses.push({
150
- tag,
151
- line: startLine,
152
- attrs,
153
- hasChildren: selfClosing ? false : undefined,
154
- hasSpread: spread
155
- });
156
- advance(j);
157
- if (RAW_TEXT.has(tag.toLowerCase()) && !selfClosing) {
158
- const close = code.toLowerCase().indexOf(`</${tag.toLowerCase()}`, i);
159
- advance(close < 0 ? n : close);
160
- }
161
- }
162
- return uses;
163
- }
164
- //# sourceMappingURL=scan.js.map
@@ -1,239 +0,0 @@
1
- import { scanJsx, scanMarkup } from "./scan.js";
2
- import { normalize } from "./system.js";
3
- import { validateUsage } from "./validate-usage.js";
4
- export const MAX_CODE_CHARS = 200_000;
5
- export const MAX_FINDINGS = 200;
6
- const NATIVE_EQUIVALENTS = {
7
- button: ["button"],
8
- input: ["input", "textfield", "textinput"],
9
- select: ["select", "dropdown"],
10
- textarea: ["textarea", "textfield"],
11
- dialog: ["dialog", "modal"],
12
- table: ["table", "datatable"],
13
- a: ["link"],
14
- img: ["image", "img"],
15
- label: ["label"],
16
- progress: ["progress", "progressbar"]
17
- };
18
- const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9A-Za-z_-])/g;
19
- const CSS_VAR = /var\(\s*(--[A-Za-z0-9_-]+)/g;
20
- function expandHex(hex) {
21
- const h = hex.slice(1).toLowerCase();
22
- return `#${h.length <= 4 ? [...h].map((c) => c + c).join("") : h}`;
23
- }
24
- function tagIndex(ds) {
25
- const map = new Map();
26
- const add = (key, c) => {
27
- if (key === "")
28
- return;
29
- const list = map.get(key) ?? [];
30
- if (!list.includes(c))
31
- list.push(c);
32
- map.set(key, list);
33
- };
34
- for (const c of ds.contract.components) {
35
- add(normalize(c.name), c);
36
- for (const i of c.implementations) {
37
- if (i.exportName)
38
- add(normalize(i.exportName), c);
39
- const sel = i.extensions?.[i.framework]?.selector;
40
- if (typeof sel === "string")
41
- add(normalize(/^[a-z][a-z0-9-]*/.exec(sel)?.[0] ?? ""), c);
42
- }
43
- }
44
- return map;
45
- }
46
- function tokenColors(ds) {
47
- const colors = new Map();
48
- for (const t of ds.contract.tokens) {
49
- const values = [
50
- ds.resolveToken(t).value,
51
- ...Object.keys(t.modes ?? {}).map((m) => ds.resolveToken(t, m).value)
52
- ];
53
- for (const v of values)
54
- if (typeof v === "string" && /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v.trim())) {
55
- const key = expandHex(v.trim());
56
- if (!colors.has(key))
57
- colors.set(key, t.id);
58
- }
59
- }
60
- return colors;
61
- }
62
- function cssVariableNames(ds) {
63
- const names = new Set();
64
- for (const t of ds.contract.tokens) {
65
- const css = t.extensions?.css;
66
- if (typeof css?.name === "string")
67
- names.add(css.name);
68
- names.add(t.name.startsWith("--") ? t.name : `--${t.name.replace(/\./g, "-")}`);
69
- }
70
- return names;
71
- }
72
- /** From the file name, else the contract's only framework, else React. */
73
- export function defaultFramework(ds, filename) {
74
- if (filename) {
75
- if (/\.vue$/i.test(filename))
76
- return "vue";
77
- if (/\.component\.html$/i.test(filename))
78
- return "angular";
79
- if (/\.html?$/i.test(filename))
80
- return "html";
81
- if (/\.[cm]?[jt]sx?$/i.test(filename))
82
- return "react";
83
- }
84
- const known = ds.contract.metadata.frameworks.filter((f) => f === "react" || f === "vue" || f === "angular");
85
- return known.length === 1 ? known[0] : "react";
86
- }
87
- /**
88
- * Checks a code snippet against the contract without running it: uses of known components are validated
89
- * with their literal props, literal colors and CSS variables are compared with the tokens. Dynamic values
90
- * are counted, not guessed.
91
- */
92
- export function validateCode(ds, input) {
93
- const framework = input.framework ?? defaultFramework(ds, input.filename);
94
- const findings = [];
95
- const limitations = [
96
- "Static check of the given snippet only: nothing is executed or imported.",
97
- "Only literal prop values are verified; expressions and bindings are counted as unverified.",
98
- "A component is recognized by name or selector. Components from other libraries are not checked."
99
- ];
100
- const checked = new Set();
101
- const unknown = new Set();
102
- let unverified = 0;
103
- if (input.code.length > MAX_CODE_CHARS) {
104
- return {
105
- valid: false,
106
- framework,
107
- summary: {
108
- errors: 1,
109
- warnings: 0,
110
- info: 0,
111
- componentsChecked: [],
112
- elementsNotInContract: [],
113
- unverifiedValues: 0
114
- },
115
- findings: [
116
- {
117
- code: "input-too-large",
118
- severity: "error",
119
- message: `The snippet is ${input.code.length} characters; the limit is ${MAX_CODE_CHARS}`,
120
- basis: "discovered"
121
- }
122
- ],
123
- truncated: false,
124
- limitations
125
- };
126
- }
127
- const index = tagIndex(ds);
128
- const uses = framework === "react" ? scanJsx(input.code, input.filename) : scanMarkup(input.code);
129
- for (const use of uses) {
130
- const key = normalize(use.tag.replace(/^.*:/, ""));
131
- // A lowercase tag without a hyphen is a native element in every framework (custom elements need a hyphen).
132
- const nativeTag = /^[a-z][a-z0-9]*$/.test(use.tag);
133
- const matches = nativeTag ? [] : (index.get(key) ?? []);
134
- if (matches.length === 1) {
135
- const result = validateUsage(ds, {
136
- component: matches[0].id,
137
- framework: framework === "html" ? undefined : framework,
138
- props: use.attrs,
139
- hasChildren: use.hasChildren,
140
- hasSpread: use.hasSpread
141
- });
142
- checked.add(matches[0].id);
143
- unverified += result.unverifiedValues;
144
- for (const f of result.findings)
145
- findings.push({ ...f, line: use.line });
146
- continue;
147
- }
148
- if (matches.length > 1) {
149
- findings.push({
150
- code: "component-ambiguous",
151
- severity: "info",
152
- message: `<${use.tag}> matches ${matches.map((m) => m.id).join(", ")}; it was not checked`,
153
- line: use.line,
154
- basis: "discovered"
155
- });
156
- continue;
157
- }
158
- const native = NATIVE_EQUIVALENTS[use.tag.toLowerCase()];
159
- const equivalent = native
160
- ? ds.contract.components.find((c) => native.includes(normalize(c.name)))
161
- : undefined;
162
- if (equivalent)
163
- findings.push({
164
- code: "native-element-has-equivalent",
165
- severity: "info",
166
- message: `<${use.tag}> is a native element and the design system has ${equivalent.name}. This is a name match, not a rule`,
167
- line: use.line,
168
- basis: "heuristic",
169
- confidence: 0.5
170
- });
171
- else if (/^[A-Z]/.test(use.tag) || use.tag.includes("-"))
172
- unknown.add(use.tag);
173
- }
174
- const colors = tokenColors(ds);
175
- if (colors.size > 0) {
176
- const lines = input.code.split("\n");
177
- lines.forEach((text, idx) => {
178
- for (const m of text.matchAll(HEX)) {
179
- const before = text.slice(Math.max(0, (m.index ?? 0) - 14), m.index ?? 0);
180
- if (/(href|to|id|name)\s*=\s*["']?$/i.test(before) || /&$/.test(before))
181
- continue;
182
- const token = colors.get(expandHex(m[0]));
183
- findings.push(token
184
- ? {
185
- code: "color-matches-token",
186
- severity: "info",
187
- message: `${m[0]} equals the value of ${token}; reference the token instead`,
188
- line: idx + 1,
189
- basis: "discovered"
190
- }
191
- : {
192
- code: "color-not-in-tokens",
193
- severity: "warning",
194
- message: `${m[0]} is a literal color that is not the value of any token`,
195
- line: idx + 1,
196
- basis: "discovered"
197
- });
198
- }
199
- });
200
- }
201
- else
202
- limitations.push("No token has a literal color value, so literal colors were not checked.");
203
- if (ds.contract.tokens.length > 0) {
204
- const known = cssVariableNames(ds);
205
- input.code.split("\n").forEach((text, idx) => {
206
- for (const m of text.matchAll(CSS_VAR)) {
207
- const name = m[1];
208
- if (!known.has(name))
209
- findings.push({
210
- code: "unknown-css-variable",
211
- severity: "warning",
212
- message: `${name} is not a token in the contract`,
213
- line: idx + 1,
214
- basis: "discovered"
215
- });
216
- }
217
- });
218
- }
219
- else
220
- limitations.push("The contract has no tokens, so CSS variables were not checked.");
221
- findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
222
- const count = (s) => findings.filter((f) => f.severity === s).length;
223
- return {
224
- valid: count("error") === 0,
225
- framework,
226
- summary: {
227
- errors: count("error"),
228
- warnings: count("warning"),
229
- info: count("info"),
230
- componentsChecked: [...checked].sort(),
231
- elementsNotInContract: [...unknown].sort().slice(0, 50),
232
- unverifiedValues: unverified
233
- },
234
- findings: findings.slice(0, MAX_FINDINGS),
235
- truncated: findings.length > MAX_FINDINGS,
236
- limitations
237
- };
238
- }
239
- //# sourceMappingURL=validate-code.js.map
@@ -1,150 +0,0 @@
1
- import { normalize } from "./system.js";
2
- export const isDynamic = (v) => typeof v === "object" &&
3
- v !== null &&
4
- !Array.isArray(v) &&
5
- Object.keys(v).length === 1 &&
6
- typeof v.expression === "string";
7
- const PASSTHROUGH = /^(data-|aria-)|^(key|ref|class|className|style|id|slot|role|tabindex|title|hidden)$/i;
8
- function sameValue(allowed, v) {
9
- if (allowed === v)
10
- return true;
11
- return typeof v === "string" && typeof allowed !== "string" && String(allowed) === v;
12
- }
13
- export function validateUsage(ds, input) {
14
- const findings = [];
15
- const found = ds.findComponent(input.component);
16
- if (!found.match) {
17
- const names = found.candidates.map((c) => c.name);
18
- findings.push({
19
- code: found.candidates.length > 1 && names.some((n) => normalize(n) === normalize(input.component))
20
- ? "component-ambiguous"
21
- : "component-unknown",
22
- severity: "error",
23
- message: `No single component "${input.component}" in the contract${names.length > 0 ? `. Candidates: ${names.join(", ")}` : ""}`,
24
- basis: "discovered"
25
- });
26
- return { findings, unverifiedValues: 0 };
27
- }
28
- const c = found.match;
29
- const framework = input.framework;
30
- if (framework && !c.implementations.some((i) => i.framework === framework))
31
- findings.push({
32
- code: "framework-not-implemented",
33
- severity: "error",
34
- message: `${c.name} has no ${framework} implementation (it has: ${c.implementations.map((i) => i.framework).join(", ")})`,
35
- basis: "discovered"
36
- });
37
- if (c.lifecycle === "deprecated")
38
- findings.push({
39
- code: "component-deprecated",
40
- severity: "warning",
41
- message: `${c.name} is deprecated`,
42
- basis: ds.factView(c.facts, "lifecycle")?.status === "documented" ? "documented" : "discovered"
43
- });
44
- const complete = ds.propsComplete(c, framework);
45
- const props = input.props ?? {};
46
- let unverified = 0;
47
- const basisOf = (path) => {
48
- const s = ds.factView(c.facts, path)?.status;
49
- return s === "documented" || s === "inferred" ? s : "discovered";
50
- };
51
- const where = (path) => {
52
- const s = ds.factView(c.facts, path)?.source;
53
- return s ? { source: s } : {};
54
- };
55
- const eventNames = new Set(c.events.map((e) => normalize(e.name)));
56
- const slotNames = new Set(c.slots.map((s) => normalize(s.name)));
57
- for (const [name, value] of Object.entries(props)) {
58
- const prop = c.props.find((p) => p.name === name);
59
- if (!prop) {
60
- if (PASSTHROUGH.test(name) ||
61
- eventNames.has(normalize(name.replace(/^on(?=[A-Z])/, ""))) ||
62
- slotNames.has(normalize(name)))
63
- continue;
64
- findings.push(complete
65
- ? {
66
- code: "unknown-prop",
67
- severity: "warning",
68
- message: `${c.name} does not declare a prop "${name}"`,
69
- path: `props.${name}`,
70
- basis: "discovered"
71
- }
72
- : {
73
- code: "unverifiable-prop",
74
- severity: "info",
75
- message: `${c.name}'s prop list is incomplete, so "${name}" could not be verified`,
76
- path: `props.${name}`,
77
- basis: "discovered"
78
- });
79
- continue;
80
- }
81
- const base = `props.${name}`;
82
- if (prop.deprecated)
83
- findings.push({
84
- code: "prop-deprecated",
85
- severity: "warning",
86
- message: `${c.name}.${name} is deprecated${prop.deprecated.reason ? `: ${prop.deprecated.reason}` : ""}`,
87
- path: base,
88
- basis: basisOf(`${base}.deprecated`),
89
- ...where(`${base}.deprecated`)
90
- });
91
- if (isDynamic(value)) {
92
- unverified++;
93
- continue;
94
- }
95
- if (prop.allowedValues &&
96
- (typeof value === "string" || typeof value === "number" || typeof value === "boolean")) {
97
- if (!prop.allowedValues.some((a) => sameValue(a, value)))
98
- findings.push({
99
- code: "prop-value-not-allowed",
100
- severity: "error",
101
- message: `${c.name}.${name} is ${JSON.stringify(value)}, which is not one of ${prop.allowedValues.map((a) => JSON.stringify(a)).join(", ")}`,
102
- path: `${base}.allowedValues`,
103
- basis: basisOf(`${base}.allowedValues`),
104
- ...where(`${base}.allowedValues`)
105
- });
106
- }
107
- else {
108
- const kind = prop.type.kind;
109
- const bad = (kind === "boolean" && typeof value !== "boolean") ||
110
- (kind === "number" && typeof value !== "number") ||
111
- (kind === "string" && typeof value !== "string");
112
- if (bad)
113
- findings.push({
114
- code: "prop-type-mismatch",
115
- severity: "warning",
116
- message: `${c.name}.${name} expects ${kind} (${prop.type.text}) but got ${JSON.stringify(value)}`,
117
- path: `${base}.type`,
118
- basis: basisOf(`${base}.type.text`),
119
- ...where(`${base}.type.text`)
120
- });
121
- }
122
- }
123
- if (!input.hasSpread)
124
- for (const p of c.props) {
125
- if (!p.required || p.name in props)
126
- continue;
127
- if (p.name === "children") {
128
- if (input.hasChildren === false)
129
- findings.push({
130
- code: "required-prop-missing",
131
- severity: "error",
132
- message: `${c.name} requires children`,
133
- path: "props.children",
134
- basis: basisOf("props.children.required"),
135
- ...where("props.children.required")
136
- });
137
- continue;
138
- }
139
- findings.push({
140
- code: "required-prop-missing",
141
- severity: "error",
142
- message: `${c.name} requires the prop "${p.name}"`,
143
- path: `props.${p.name}`,
144
- basis: basisOf(`props.${p.name}.required`),
145
- ...where(`props.${p.name}.required`)
146
- });
147
- }
148
- return { component: c.id, findings, unverifiedValues: unverified };
149
- }
150
- //# sourceMappingURL=validate-usage.js.map