From 8ccadd3849a1e73f888535d364bbf5e47919234b Mon Sep 17 00:00:00 2001 From: Juan-Pablo Scaletti Date: Sun, 15 Feb 2026 22:44:47 -0500 Subject: [PATCH 1/2] Better check --- docs/content/cli.md | 75 ------- docs/content/installable.md | 11 +- docs/content/tools/check.md | 89 ++++++++ docs/docs.py | 7 +- src/jx/__init__.py | 1 + src/jx/cli.py | 261 ++++------------------- src/jx/tools.py | 210 +++++++++++++++++++ tests/test_check.py | 333 +++++++++++++++++++++++++++++ tests/test_cli.py | 405 ++++++++++++++---------------------- 9 files changed, 843 insertions(+), 549 deletions(-) delete mode 100644 docs/content/cli.md create mode 100644 docs/content/tools/check.md create mode 100644 src/jx/tools.py create mode 100644 tests/test_check.py diff --git a/docs/content/cli.md b/docs/content/cli.md deleted file mode 100644 index 56b2206..0000000 --- a/docs/content/cli.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: Validator tool -description: Command-line tools for validating Jx components ---- - -Jx includes a command-line tool for validating your components. This helps catch errors early, and can be especially useful in CI pipelines. - -### `jx check` - -Validate components in one or more folders: - -```sh -$ jx check components/ -``` - -Check multiple folders: - -```sh -$ jx check components/ layouts/ pages/ -``` - -## What It Checks - -The `check` command validates each `.jinja` file for: - -1. **Valid UTF-8** — Files must be valid UTF-8 encoded -2. **Metadata syntax** — `{#def ...#}` and `{#import ...#}` declarations must parse correctly -3. **Import paths** — All imported components must exist in the catalog -4. **Component usage** — Every `` tag must be imported -5. **Template syntax** — Catches unclosed tags, unmatched braces, and other parse errors - -## Output Formats - -### Text (default) - -```sh -$ jx check components/ -``` - -```sh -✓ button.jinja - OK -✓ card.jinja - OK -✗ page.jinja:12 - Component 'Buton' used but not imported (did you mean 'Button'?) -✗ modal.jinja - Unknown import 'dialog.jinja' (did you mean 'dialogs/dialog.jinja'?) - -4 components checked, 2 errors -```` - -### JSON - -```sh -$ jx check --format json components/ -``` - -```json -{ - "checked": 4, - "errors": [ - { - "file": "page.jinja", - "line": 12, - "message": "Component 'Buton' used but not imported", - "suggestion": "Button" - }, - { - "file": "modal.jinja", - "line": null, - "message": "Unknown import 'dialog.jinja'", - "suggestion": "dialogs/dialog.jinja" - } - ] -} -``` - -JSON output is useful for integrating with editors, linters, or custom tooling. diff --git a/docs/content/installable.md b/docs/content/installable.md index 35eb6b2..98b03c3 100644 --- a/docs/content/installable.md +++ b/docs/content/installable.md @@ -130,18 +130,11 @@ static/pkg/ You can run this as part of your build or deploy step: -```python title="collect.py" -from myapp import catalog - -catalog.collect_assets("static/pkg") -print("Assets collected.") -``` - ```bash -python collect.py +jx collect_assets myapp.setup:catalog ./static/pkg ``` -After collecting, update your resolver (or remove it entirely) to point to the static path: +After collecting, update your resolver to point to the static path: ```python # Production: assets already at /static/pkg// diff --git a/docs/content/tools/check.md b/docs/content/tools/check.md new file mode 100644 index 0000000..f7f7096 --- /dev/null +++ b/docs/content/tools/check.md @@ -0,0 +1,89 @@ +--- +title: Validator +description: Command-line tools for validating Jx components +--- + +Jx includes a command-line tool for validating your components. This helps catch errors early, and can be especially useful in CI pipelines. + +Point the checker at your catalog instance using its Python import path: + +```sh +$ jx check myapp.setup:catalog +``` + +The format is `module.path:attribute` — the module is imported and the attribute is used as the `Catalog` instance. + +## What It Checks + +The `check` command goes beyond the validation the catalog does when preloading components: + +1. **Cross-component validation** — verifies that import paths (e.g. `{#import "buton.jinja" ...}`) actually resolve to components in the catalog. The catalog only verifies imports exist at render time. +2. **Unimported tag detection** — finds PascalCase tags like `" + ) + (folder / "card.jinja").write_text( + '{#import "button.jinja" as Button #}\n
' + ) + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 0 + + +def test_check_unknown_component(folder, capsys): + (folder / "button.jinja").write_text("") + (folder / "card.jinja").write_text("
") + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "Unknown component 'Buttn'" in captured.out + assert "did you mean 'Button'?" in captured.out + + +def test_check_unknown_import(folder, capsys): + (folder / "button.jinja").write_text("") + (folder / "card.jinja").write_text( + '{#import "buton.jinja" as Button #}\n") + (folder / "card.jinja").write_text("
") + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "Component 'Button' used but not imported" in captured.out + + +def test_check_single_file(folder, capsys): + """Test checking a single file instead of a directory.""" + (folder / "button.jinja").write_text("{#def label #}\n") + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 0 + + captured = capsys.readouterr() + assert "button.jinja - OK" in captured.out + + +def test_check_no_components(tmp_path, capsys): + """Test checking an empty directory with no components.""" + empty_folder = tmp_path / "empty" + empty_folder.mkdir() + + catalog = make_catalog(empty_folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "No components found" in captured.out + + +def test_check_invalid_utf8(folder, capsys): + """Test checking a component with invalid UTF-8 encoding.""" + (folder / "broken.jinja").write_bytes(b"
\xff\xfe invalid
") + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "broken.jinja - Not valid UTF-8" in captured.out + + +def test_check_invalid_metadata(folder, capsys): + """Test checking a component with invalid metadata syntax.""" + (folder / "broken.jinja").write_text("{#def $invalid #}\n
test
") + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "broken.jinja -" in captured.out + + +def test_check_unknown_import_no_suggestion(folder, capsys): + """Test unknown import with no similar component to suggest.""" + (folder / "card.jinja").write_text( + '{#import "xyzabc123.jinja" as Thing #}\n' + ) + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "Unknown import 'xyzabc123.jinja'" in captured.out + assert "did you mean" not in captured.out + + +def test_check_unknown_component_no_suggestion(folder, capsys): + """Test unknown component tag with no similar tag to suggest.""" + (folder / "card.jinja").write_text("
") + + catalog = make_catalog(folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "Unknown component 'Xyzabc123'" in captured.out + assert "did you mean" not in captured.out + + +def test_suggest_component(): + """Test component path suggestion.""" + all_components = {"button.jinja", "card.jinja", "layout.jinja"} + + assert suggest_component("buton.jinja", all_components) == "button.jinja" + assert suggest_component("xyzabc123.jinja", all_components) is None + + +def test_check_nonexistent_path(tmp_path, capsys): + """Test checking an empty catalog (no components).""" + empty_folder = tmp_path / "does_not_exist" + empty_folder.mkdir() + + catalog = make_catalog(empty_folder) + exit_code = check(catalog) + assert exit_code == 1 + + captured = capsys.readouterr() + assert "No components found" in captured.out + + +def test_check_all_valid(folder): + """Test check_all returns no errors for valid components.""" + (folder / "button.jinja").write_text( + "{#def label #}\n" + ) + (folder / "card.jinja").write_text( + '{#import "button.jinja" as Button #}\n
' + ) + + catalog = make_catalog(folder) + errors, checked = check_all(catalog) + assert checked == 2 + assert errors == [] + + +def test_check_all_with_errors(folder): + """Test check_all returns structured errors.""" + (folder / "button.jinja").write_text("") + (folder / "card.jinja").write_text("
") + + catalog = make_catalog(folder) + errors, checked = check_all(catalog) + assert checked == 2 + assert len(errors) == 1 + assert errors[0].file == "card.jinja" + assert errors[0].line == 1 + assert "Buttn" in errors[0].message + assert errors[0].suggestion == "Button" + + +def test_check_all_single_file(folder): + """Test check_all with a catalog containing one component.""" + (folder / "button.jinja").write_text("{#def label #}\n") + + catalog = make_catalog(folder) + errors, checked = check_all(catalog) + assert checked == 1 + assert errors == [] + + +def test_check_all_empty(tmp_path): + """Test check_all with no components returns zero checked.""" + empty_folder = tmp_path / "empty" + empty_folder.mkdir() + + catalog = make_catalog(empty_folder) + errors, checked = check_all(catalog) + assert checked == 0 + assert errors == [] + + +def test_check_json_format_valid(folder, capsys): + """Test JSON output format with valid components.""" + (folder / "button.jinja").write_text("") + + catalog = make_catalog(folder) + exit_code = check(catalog, format="json") + assert exit_code == 0 + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["checked"] == 1 + assert result["errors"] == [] + + +def test_check_json_format_with_errors(folder, capsys): + """Test JSON output format with errors.""" + (folder / "button.jinja").write_text("") + (folder / "card.jinja").write_text("
") + + catalog = make_catalog(folder) + exit_code = check(catalog, format="json") + assert exit_code == 1 + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["checked"] == 2 + assert len(result["errors"]) == 1 + assert result["errors"][0]["file"] == "card.jinja" + assert result["errors"][0]["line"] == 1 + assert "Buttn" in result["errors"][0]["message"] + assert result["errors"][0]["suggestion"] == "Button" + + +def test_check_json_format_no_components(tmp_path, capsys): + """Test JSON output format with no components.""" + empty_folder = tmp_path / "empty" + empty_folder.mkdir() + + catalog = make_catalog(empty_folder) + exit_code = check(catalog, format="json") + assert exit_code == 0 + + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["checked"] == 0 + assert result["errors"] == [] + + +def test_check_unclosed_component_tag(folder, capsys): + """Test that an unclosed component tag is detected.""" + (folder / "footer.jinja").write_text("
Footer
") + (folder / "page.jinja").write_text( + '{#import "footer.jinja" as Footer #}\n