Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"tree-sitter-cfengine>=1.1.12",
"tree-sitter>=0.25",
"markdown-it-py>=3.0.0",
"PyYAML>=6.0.3",
]
classifiers = [
"Development Status :: 3 - Alpha",
Expand Down
20 changes: 19 additions & 1 deletion src/cfengine_cli/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- cfbs.json (CFEngine Build project files)
- *.json (basic JSON syntax checking)
- *.csv (basic CSV syntax + RFC 4180 CRLF record terminator check)
- *.yml / *.yaml (basic YAML syntax checking)

This is performed in 3 steps:
1. Parsing - Read the .cf files and convert them into syntax trees
Expand Down Expand Up @@ -40,9 +41,10 @@
from cfbs.cfbs_config import CFBSConfig
from cfbs.utils import find
from cfengine_cli.lint_csv import check_csv_file
from cfengine_cli.lint_yml import check_yml_file
from cfengine_cli.utils import UserError

LINT_EXTENSIONS = (".cf", ".cf.sub", ".json", ".csv")
LINT_EXTENSIONS = (".cf", ".cf.sub", ".json", ".csv", ".yml", ".yaml")
DEFAULT_NAMESPACE = "default"
VARS_TYPES = {
"data",
Expand Down Expand Up @@ -1196,6 +1198,9 @@ def _lint_main(
if filename.endswith(".csv"):
errors += _lint_csv(filename)
continue
if filename.endswith((".yml", ".yaml")):
errors += _lint_yml(filename)
continue
assert filename.endswith((".cf", ".cf.sub"))
policy_file = PolicyFile(filename, snippet)
r = _check_syntax(policy_file, state)
Expand Down Expand Up @@ -1346,6 +1351,19 @@ def _lint_csv(filename: str) -> int:
return r


def _lint_yml(filename: str) -> int:
"""Lint a YAML file: check that it parses and contains at least one
non-null document."""
assert os.path.isfile(filename)
problem = check_yml_file(filename)
r = 0
if problem is not None:
print(f"{filename}: {problem}")
r = 1
print(_pass_fail_filename(filename, r))
return r


# ---------------------------------------------------------------------------
# Syntax error detection (used by both linter and formatter)
# ---------------------------------------------------------------------------
Expand Down
25 changes: 25 additions & 0 deletions src/cfengine_cli/lint_yml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""YAML file validation.

Checks that a YAML file parses successfully (per the PyYAML safe loader)
and is not empty. Null documents (e.g. a bare `---`) are accepted.
"""

import yaml


def check_yml_file(filename: str) -> str | None:
"""Check a YAML file: parses, and is not empty.

Null documents (a bare document marker with no content) are accepted;
only files where PyYAML produces no documents at all are rejected.

Returns None if valid, otherwise a short description of the problem.
"""
try:
with open(filename, "r") as f:
documents = list(yaml.safe_load_all(f))
except (OSError, yaml.YAMLError) as e:
return str(e)
if not documents:
return "empty file"
return None
12 changes: 10 additions & 2 deletions tests/shell/005-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ printf "" > "$tmpdir/empty.csv"
# Empty policy file
printf "" > "$tmpdir/empty.cf"

# Empty YAML file
printf "" > "$tmpdir/empty.yml"

# CSV with LF-only line endings
printf 'a,b,c\n1,2,3\n' > "$tmpdir/bad.csv"

Expand All @@ -26,6 +29,9 @@ printf 'abc\n' > "$tmpdir/bad.json"
# Policy file with just some characters
printf 'abc\n' > "$tmpdir/bad.cf"

# YAML with unclosed flow sequence
printf 'items: [1, 2, 3\n' > "$tmpdir/bad.yaml"

# Run lint on the folder - expect non-zero exit
if cfengine lint "$tmpdir" > "$output_file" 2>&1; then
cat "$output_file"
Expand All @@ -38,9 +44,11 @@ cat "$output_file"
grep -q "FAIL:.*empty.json" "$output_file"
grep -q "FAIL:.*empty.csv" "$output_file"
grep -q "FAIL:.*empty.cf" "$output_file"
grep -q "FAIL:.*empty.yml" "$output_file"
grep -q "FAIL:.*bad.csv" "$output_file"
grep -q "FAIL:.*bad.json" "$output_file"
grep -q "FAIL:.*bad.cf" "$output_file"
grep -q "FAIL:.*bad.yaml" "$output_file"

# Verify total error count is 6
grep -q "Failure, 6 errors in total" "$output_file"
# Verify total error count is 8
grep -q "Failure, 8 errors in total" "$output_file"
54 changes: 54 additions & 0 deletions tests/unit/test_lint_yml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os
import tempfile

from cfengine_cli.lint_yml import check_yml_file


def _write_temp_yml(content: bytes) -> str:
fd, path = tempfile.mkstemp(suffix=".yml")
with os.fdopen(fd, "wb") as f:
f.write(content)
return path


VALID = [
("simple_mapping", b"key: value\n"),
("nested_mapping", b"top:\n nested: value\n list:\n - 1\n - 2\n"),
("flow_sequence", b"items: [1, 2, 3]\n"),
("flow_mapping", b"obj: {a: 1, b: 2}\n"),
("multi_document", b"---\na: 1\n---\nb: 2\n"),
("scalar_only", b"42\n"),
("string_only", b'"hello"\n'),
("list_only", b"- one\n- two\n"),
("multi_doc_first_null", b"---\n---\nname: cfengine\n"),
("only_null_document", b"---\n"),
("multiple_null_documents", b"---\n---\n---\n"),
]

INVALID = [
("empty_file", b""),
("only_whitespace", b" \n\n"),
("unclosed_flow_sequence", b"items: [1, 2, 3\n"),
("unclosed_flow_mapping", b"obj: {a: 1, b: 2\n"),
("bad_indentation", b"top:\n nested: value\n bad: value\n"),
("tab_indentation", b"top:\n\tnested: value\n"),
("duplicate_key_via_alias", b"a: &x 1\nb: *y\n"),
]


def test_check_yml_file_accepts_valid():
for name, content in VALID:
path = _write_temp_yml(content)
try:
assert check_yml_file(path) is None, f"Expected valid: {name}"
finally:
os.unlink(path)


def test_check_yml_file_rejects_invalid():
for name, content in INVALID:
path = _write_temp_yml(content)
try:
assert check_yml_file(path) is not None, f"Expected invalid: {name}"
finally:
os.unlink(path)
Loading
Loading