jevkit-lint 0.1.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .ruff_cache/
12
+ uv.lock
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevkit-lint
3
+ Version: 0.1.0
4
+ Summary: Static linter for TypeSafe Jev questions. Catches the documented jev-1.13 failure modes before you spend a token. No API key required.
5
+ Project-URL: Homepage, https://github.com/pjdurden/jevkit-py
6
+ Project-URL: Issues, https://github.com/pjdurden/jevkit-py/issues
7
+ Author: Prajjwal Chittori
8
+ License-Expression: MIT
9
+ Keywords: jev,lint,linter,static-analysis,system-one,typesafe
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: jevkit-core>=0.1.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # jevkit-lint
22
+
23
+ Static linter for [TypeSafe](https://typesafe.ai) Jev questions.
24
+
25
+ TypeSafe publishes a [list of failure modes](https://docs.typesafe.ai/model-jaggedness/jev-1.13)
26
+ for `jev-1.13`: it reads instructions literally, it cannot count, it reads dates
27
+ as text rather than ordered quantities, it loses accuracy on indirection. Most
28
+ of those are visible in your question definitions before you send anything.
29
+
30
+ `jevkit-lint` reads the definitions and tells you. It never calls the API, so it
31
+ needs no key and costs nothing to run in CI.
32
+
33
+ > Unofficial and unaffiliated with TypeSafe.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install jevkit-lint
39
+ ```
40
+
41
+ ## Use
42
+
43
+ ```python
44
+ from jevkit_lint import lint
45
+
46
+ result = lint({
47
+ "urgency": {"type": "noul", "instructions": "How many days has the customer waited?"},
48
+ "team": {"type": "choice", "instructions": "Which team should handle this",
49
+ "criteria": {"billing": "Payment issues", "technical": "Bugs"}},
50
+ })
51
+
52
+ print(result.format())
53
+ ```
54
+
55
+ ```
56
+ urgency: error [JEV001] Question asks the model to count or do arithmetic.
57
+ found: How many
58
+ hint: jev-1.13 is not a calculator and does not count reliably. Iterate the
59
+ candidates in code, ask one Noul per item, and sum the answers yourself.
60
+ team: warning [JEV008] Choice has no no-match option.
61
+ found: billing, technical
62
+ hint: A Choice always returns one of its options. With nothing meaning 'none of
63
+ these', a state that fits no option still produces a confident-looking
64
+ answer. Add an explicit none/unknown option, or gate on a separate
65
+ presence Noul.
66
+ ```
67
+
68
+ `result.ok` is `True` when nothing rose to an error, so it drops straight into a
69
+ guard:
70
+
71
+ ```python
72
+ if not lint(questions, state).ok:
73
+ raise ValueError("refusing to send a request that will not answer what we meant")
74
+ ```
75
+
76
+ ## CLI
77
+
78
+ ```bash
79
+ jevkit-lint request.json # a {state, questions} object, or bare questions
80
+ jevkit-lint cassette.jevl # lint every recorded request
81
+ jevkit-lint - < request.json # stdin
82
+ jevkit-lint request.json --strict # exit non-zero on warnings too
83
+ jevkit-lint request.json --format json
84
+ jevkit-lint --list-rules
85
+ ```
86
+
87
+ Exit codes: `0` clean, `1` findings, `2` bad usage.
88
+
89
+ ## Rules
90
+
91
+ Each rule names the documented failure mode it comes from.
92
+
93
+ | Code | Rule | Failure mode |
94
+ | --- | --- | --- |
95
+ | JEV001 | math-and-counting | Math and Numbers |
96
+ | JEV002 | date-comparison | Date and time comparison |
97
+ | JEV003 | generation-request | Generation |
98
+ | JEV004 | negation | Literal reading |
99
+ | JEV005 | vague-scoping | Literal reading |
100
+ | JEV006 | indirection | Indirection |
101
+ | JEV007 | noul-polarity | Contradictory instructions and criteria |
102
+ | JEV008 | choice-no-match | Common-sense structural invariants |
103
+ | JEV009 | choice-arity | Common-sense structural invariants |
104
+ | JEV010 | option-descriptions | Literal reading |
105
+ | JEV011 | score-levels | Literal reading |
106
+ | JEV012 | instructions-present | Literal reading |
107
+ | JEV013 | question-id-reference | Indirection |
108
+ | JEV014 | state-budget | Large state full of irrelevant detail |
109
+ | JEV015 | total-budget | Large state full of irrelevant detail |
110
+ | JEV016 | state-noise-ratio | Large state full of irrelevant detail |
111
+ | JEV017 | numeric-representation | Math and Numbers |
112
+ | JEV018 | duplicate-questions | Common-sense structural invariants |
113
+
114
+ Select or suppress by code:
115
+
116
+ ```python
117
+ lint(questions, select=["JEV001", "JEV002"])
118
+ lint(questions, ignore=["JEV008"])
119
+ ```
120
+
121
+ ## What it cannot do
122
+
123
+ Adversarial content is a documented failure mode and is **not** linted. Whether
124
+ a state is hostile depends on the content at runtime, not on the question
125
+ definition, so a static rule would be theatre. Screen untrusted state at
126
+ request time instead.
127
+
128
+ Token counts are estimates. jevkit deliberately does not bundle a tokenizer:
129
+ TypeSafe does not publish which one Jev uses, and a confidently wrong count is
130
+ worse than an honest approximation. The estimator errs conservative, so leave
131
+ headroom near the limits.
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,115 @@
1
+ # jevkit-lint
2
+
3
+ Static linter for [TypeSafe](https://typesafe.ai) Jev questions.
4
+
5
+ TypeSafe publishes a [list of failure modes](https://docs.typesafe.ai/model-jaggedness/jev-1.13)
6
+ for `jev-1.13`: it reads instructions literally, it cannot count, it reads dates
7
+ as text rather than ordered quantities, it loses accuracy on indirection. Most
8
+ of those are visible in your question definitions before you send anything.
9
+
10
+ `jevkit-lint` reads the definitions and tells you. It never calls the API, so it
11
+ needs no key and costs nothing to run in CI.
12
+
13
+ > Unofficial and unaffiliated with TypeSafe.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install jevkit-lint
19
+ ```
20
+
21
+ ## Use
22
+
23
+ ```python
24
+ from jevkit_lint import lint
25
+
26
+ result = lint({
27
+ "urgency": {"type": "noul", "instructions": "How many days has the customer waited?"},
28
+ "team": {"type": "choice", "instructions": "Which team should handle this",
29
+ "criteria": {"billing": "Payment issues", "technical": "Bugs"}},
30
+ })
31
+
32
+ print(result.format())
33
+ ```
34
+
35
+ ```
36
+ urgency: error [JEV001] Question asks the model to count or do arithmetic.
37
+ found: How many
38
+ hint: jev-1.13 is not a calculator and does not count reliably. Iterate the
39
+ candidates in code, ask one Noul per item, and sum the answers yourself.
40
+ team: warning [JEV008] Choice has no no-match option.
41
+ found: billing, technical
42
+ hint: A Choice always returns one of its options. With nothing meaning 'none of
43
+ these', a state that fits no option still produces a confident-looking
44
+ answer. Add an explicit none/unknown option, or gate on a separate
45
+ presence Noul.
46
+ ```
47
+
48
+ `result.ok` is `True` when nothing rose to an error, so it drops straight into a
49
+ guard:
50
+
51
+ ```python
52
+ if not lint(questions, state).ok:
53
+ raise ValueError("refusing to send a request that will not answer what we meant")
54
+ ```
55
+
56
+ ## CLI
57
+
58
+ ```bash
59
+ jevkit-lint request.json # a {state, questions} object, or bare questions
60
+ jevkit-lint cassette.jevl # lint every recorded request
61
+ jevkit-lint - < request.json # stdin
62
+ jevkit-lint request.json --strict # exit non-zero on warnings too
63
+ jevkit-lint request.json --format json
64
+ jevkit-lint --list-rules
65
+ ```
66
+
67
+ Exit codes: `0` clean, `1` findings, `2` bad usage.
68
+
69
+ ## Rules
70
+
71
+ Each rule names the documented failure mode it comes from.
72
+
73
+ | Code | Rule | Failure mode |
74
+ | --- | --- | --- |
75
+ | JEV001 | math-and-counting | Math and Numbers |
76
+ | JEV002 | date-comparison | Date and time comparison |
77
+ | JEV003 | generation-request | Generation |
78
+ | JEV004 | negation | Literal reading |
79
+ | JEV005 | vague-scoping | Literal reading |
80
+ | JEV006 | indirection | Indirection |
81
+ | JEV007 | noul-polarity | Contradictory instructions and criteria |
82
+ | JEV008 | choice-no-match | Common-sense structural invariants |
83
+ | JEV009 | choice-arity | Common-sense structural invariants |
84
+ | JEV010 | option-descriptions | Literal reading |
85
+ | JEV011 | score-levels | Literal reading |
86
+ | JEV012 | instructions-present | Literal reading |
87
+ | JEV013 | question-id-reference | Indirection |
88
+ | JEV014 | state-budget | Large state full of irrelevant detail |
89
+ | JEV015 | total-budget | Large state full of irrelevant detail |
90
+ | JEV016 | state-noise-ratio | Large state full of irrelevant detail |
91
+ | JEV017 | numeric-representation | Math and Numbers |
92
+ | JEV018 | duplicate-questions | Common-sense structural invariants |
93
+
94
+ Select or suppress by code:
95
+
96
+ ```python
97
+ lint(questions, select=["JEV001", "JEV002"])
98
+ lint(questions, ignore=["JEV008"])
99
+ ```
100
+
101
+ ## What it cannot do
102
+
103
+ Adversarial content is a documented failure mode and is **not** linted. Whether
104
+ a state is hostile depends on the content at runtime, not on the question
105
+ definition, so a static rule would be theatre. Screen untrusted state at
106
+ request time instead.
107
+
108
+ Token counts are estimates. jevkit deliberately does not bundle a tokenizer:
109
+ TypeSafe does not publish which one Jev uses, and a confidently wrong count is
110
+ worse than an honest approximation. The estimator errs conservative, so leave
111
+ headroom near the limits.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "jevkit-lint"
3
+ version = "0.1.0"
4
+ description = "Static linter for TypeSafe Jev questions. Catches the documented jev-1.13 failure modes before you spend a token. No API key required."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Prajjwal Chittori" }]
9
+ keywords = ["jev", "typesafe", "system-one", "lint", "linter", "static-analysis"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Quality Assurance",
18
+ ]
19
+ dependencies = ["jevkit-core>=0.1.0"]
20
+
21
+ [project.scripts]
22
+ jevkit-lint = "jevkit_lint.cli:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/pjdurden/jevkit-py"
26
+ Issues = "https://github.com/pjdurden/jevkit-py/issues"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/jevkit_lint"]
@@ -0,0 +1,14 @@
1
+ """Static linter for TypeSafe Jev questions.
2
+
3
+ Catches the failure modes TypeSafe documents for jev-1.13 before you spend a
4
+ token on them. Needs no API key.
5
+ """
6
+
7
+ from .diagnostic import Diagnostic, Severity
8
+ from .linter import LintResult, lint
9
+ from .rules import RULES, Rule, all_codes
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["lint", "LintResult", "Diagnostic", "Severity", "Rule", "RULES",
14
+ "all_codes", "__version__"]
@@ -0,0 +1,140 @@
1
+ """``jevkit-lint`` command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ from jevkit_core import RecordFormatError, read_records
11
+
12
+ from .diagnostic import Severity
13
+ from .linter import lint
14
+ from .rules import RULES
15
+
16
+ EXIT_OK = 0
17
+ EXIT_FINDINGS = 1
18
+ EXIT_USAGE = 2
19
+
20
+
21
+ def _load_payload(path: str) -> list[tuple[str, Any, dict[str, Any]]]:
22
+ """Return (label, state, questions) triples from a file.
23
+
24
+ Accepts a `.jevl` record file, a request object with `state` and
25
+ `questions`, or a bare questions object.
26
+ """
27
+ if path == "-":
28
+ text = sys.stdin.read()
29
+ source = "<stdin>"
30
+ else:
31
+ with open(path, "r", encoding="utf-8") as fh:
32
+ text = fh.read()
33
+ source = path
34
+
35
+ if path.endswith(".jevl"):
36
+ import io
37
+ out = []
38
+ for i, record in enumerate(read_records(io.StringIO(text))):
39
+ out.append((f"{source}#{i}", record.state, record.questions))
40
+ return out
41
+
42
+ data = json.loads(text)
43
+ if not isinstance(data, dict):
44
+ raise ValueError(f"{source}: expected a JSON object at the top level")
45
+
46
+ if "questions" in data and isinstance(data["questions"], dict):
47
+ return [(source, data.get("state", ""), data["questions"])]
48
+ return [(source, "", data)]
49
+
50
+
51
+ def _print_rules() -> None:
52
+ width = max(len(r.name) for r in RULES)
53
+ print("code name" + " " * (width - 4) + " documented failure mode")
54
+ print("-" * (8 + width + 2 + 40))
55
+ for rule in RULES:
56
+ print(f"{rule.code} {rule.name:<{width}} {rule.mode}")
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ parser = argparse.ArgumentParser(
61
+ prog="jevkit-lint",
62
+ description="Statically lint TypeSafe Jev questions against the documented "
63
+ "jev-1.13 failure modes. Never calls the API.",
64
+ )
65
+ parser.add_argument("files", nargs="*",
66
+ help="JSON request files, .jevl record files, or - for stdin")
67
+ parser.add_argument("--select", metavar="CODES",
68
+ help="comma-separated rule codes to run exclusively")
69
+ parser.add_argument("--ignore", metavar="CODES",
70
+ help="comma-separated rule codes to skip")
71
+ parser.add_argument("--format", choices=("text", "json"), default="text")
72
+ parser.add_argument("--strict", action="store_true",
73
+ help="exit non-zero on warnings too, not just errors")
74
+ parser.add_argument("--list-rules", action="store_true", help="list rules and exit")
75
+ parser.add_argument("--no-color", action="store_true")
76
+ args = parser.parse_args(argv)
77
+
78
+ if args.list_rules:
79
+ _print_rules()
80
+ return EXIT_OK
81
+
82
+ if not args.files:
83
+ parser.error("no input files (use - to read stdin, or --list-rules)")
84
+
85
+ select = args.select.split(",") if args.select else None
86
+ ignore = args.ignore.split(",") if args.ignore else None
87
+ color = sys.stdout.isatty() and not args.no_color
88
+
89
+ payloads: list[tuple[str, Any, dict[str, Any]]] = []
90
+ for path in args.files:
91
+ try:
92
+ payloads.extend(_load_payload(path))
93
+ except (OSError, ValueError, RecordFormatError) as exc:
94
+ print(f"jevkit-lint: {exc}", file=sys.stderr)
95
+ return EXIT_USAGE
96
+
97
+ reports = []
98
+ worst = Severity.INFO
99
+ any_error = any_warning = False
100
+
101
+ for label, state, questions in payloads:
102
+ try:
103
+ result = lint(questions, state, select=select, ignore=ignore)
104
+ except ValueError as exc:
105
+ print(f"jevkit-lint: {label}: {exc}", file=sys.stderr)
106
+ return EXIT_USAGE
107
+ any_error = any_error or bool(result.errors)
108
+ any_warning = any_warning or bool(result.warnings)
109
+ reports.append((label, result))
110
+
111
+ if args.format == "json":
112
+ print(json.dumps(
113
+ {"results": [{"source": label, **result.to_dict()} for label, result in reports]},
114
+ indent=2,
115
+ ))
116
+ else:
117
+ for label, result in reports:
118
+ if len(payloads) > 1:
119
+ print(f"== {label}")
120
+ print(result.format(color=color))
121
+ if len(payloads) > 1:
122
+ print()
123
+ total = {"error": 0, "warning": 0, "info": 0}
124
+ for _, result in reports:
125
+ for key, value in result.counts().items():
126
+ total[key] += value
127
+ if sum(total.values()):
128
+ print(f"\n{total['error']} error(s), {total['warning']} warning(s), "
129
+ f"{total['info']} info")
130
+
131
+ del worst
132
+ if any_error:
133
+ return EXIT_FINDINGS
134
+ if args.strict and any_warning:
135
+ return EXIT_FINDINGS
136
+ return EXIT_OK
137
+
138
+
139
+ if __name__ == "__main__": # pragma: no cover
140
+ raise SystemExit(main())
@@ -0,0 +1,57 @@
1
+ """Diagnostics produced by the linter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ __all__ = ["Severity", "Diagnostic"]
10
+
11
+
12
+ class Severity(str, Enum):
13
+ ERROR = "error" # Will very likely produce wrong answers or be rejected.
14
+ WARNING = "warning" # A documented failure mode is in play.
15
+ INFO = "info" # Worth a look; may be intentional.
16
+
17
+ def __str__(self) -> str: # pragma: no cover - display only
18
+ return self.value
19
+
20
+
21
+ _ORDER = {Severity.ERROR: 0, Severity.WARNING: 1, Severity.INFO: 2}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Diagnostic:
26
+ code: str
27
+ severity: Severity
28
+ question_id: str | None
29
+ message: str
30
+ hint: str
31
+ evidence: str = ""
32
+
33
+ @property
34
+ def sort_key(self) -> tuple[int, str, str]:
35
+ return (_ORDER[self.severity], self.code, self.question_id or "")
36
+
37
+ def format(self, *, color: bool = False) -> str:
38
+ loc = self.question_id or "<request>"
39
+ head = f"{loc}: {self.severity.value} [{self.code}] {self.message}"
40
+ if color:
41
+ tint = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m"}
42
+ head = f"{tint[self.severity.value]}{head}\033[0m"
43
+ lines = [head]
44
+ if self.evidence:
45
+ lines.append(f" found: {self.evidence}")
46
+ lines.append(f" hint: {self.hint}")
47
+ return "\n".join(lines)
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "code": self.code,
52
+ "severity": self.severity.value,
53
+ "question_id": self.question_id,
54
+ "message": self.message,
55
+ "hint": self.hint,
56
+ "evidence": self.evidence,
57
+ }
@@ -0,0 +1,92 @@
1
+ """The lint entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable
6
+
7
+ from jevkit_core import normalize_questions
8
+
9
+ from .diagnostic import Diagnostic, Severity
10
+ from .rules import LintContext, rules_for
11
+
12
+ __all__ = ["lint", "LintResult"]
13
+
14
+
15
+ class LintResult:
16
+ """Diagnostics for one request, ordered most severe first."""
17
+
18
+ def __init__(self, diagnostics: list[Diagnostic]) -> None:
19
+ self.diagnostics = sorted(diagnostics, key=lambda d: d.sort_key)
20
+
21
+ def __iter__(self):
22
+ return iter(self.diagnostics)
23
+
24
+ def __len__(self) -> int:
25
+ return len(self.diagnostics)
26
+
27
+ def __bool__(self) -> bool:
28
+ return bool(self.diagnostics)
29
+
30
+ def by_severity(self, severity: Severity) -> list[Diagnostic]:
31
+ return [d for d in self.diagnostics if d.severity is severity]
32
+
33
+ @property
34
+ def errors(self) -> list[Diagnostic]:
35
+ return self.by_severity(Severity.ERROR)
36
+
37
+ @property
38
+ def warnings(self) -> list[Diagnostic]:
39
+ return self.by_severity(Severity.WARNING)
40
+
41
+ @property
42
+ def infos(self) -> list[Diagnostic]:
43
+ return self.by_severity(Severity.INFO)
44
+
45
+ @property
46
+ def ok(self) -> bool:
47
+ """True when nothing rose to an error."""
48
+ return not self.errors
49
+
50
+ def counts(self) -> dict[str, int]:
51
+ return {
52
+ "error": len(self.errors),
53
+ "warning": len(self.warnings),
54
+ "info": len(self.infos),
55
+ }
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "ok": self.ok,
60
+ "counts": self.counts(),
61
+ "diagnostics": [d.to_dict() for d in self.diagnostics],
62
+ }
63
+
64
+ def format(self, *, color: bool = False) -> str:
65
+ if not self.diagnostics:
66
+ return "No problems found."
67
+ return "\n".join(d.format(color=color) for d in self.diagnostics)
68
+
69
+
70
+ def lint(
71
+ questions: dict[str, Any],
72
+ state: Any = "",
73
+ *,
74
+ select: Iterable[str] | None = None,
75
+ ignore: Iterable[str] | None = None,
76
+ ) -> LintResult:
77
+ """Lint a jev request without calling the API.
78
+
79
+ ``questions`` accepts SDK objects or plain dicts. ``state`` is optional: omit
80
+ it to check the questions alone, though the budget rules can only report
81
+ meaningfully when the real state is supplied.
82
+ """
83
+ normalized = normalize_questions(questions)
84
+ ctx = LintContext.build(state, normalized)
85
+ ignored = {c.upper() for c in (ignore or ())}
86
+
87
+ found: list[Diagnostic] = []
88
+ for rule in rules_for(select):
89
+ if rule.code in ignored:
90
+ continue
91
+ found.extend(rule.check(ctx))
92
+ return LintResult(found)