arango-query-core 0.2.1__tar.gz → 0.2.3__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.
- arango_query_core-0.2.3/.claude/settings.json +8 -0
- arango_query_core-0.2.3/.cursor/rules/fix-upstream-dont-fork.mdc +104 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/CHANGELOG.md +41 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/PKG-INFO +1 -1
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/mapping.py +47 -2
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/engine.py +21 -3
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/postconditions.py +38 -9
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/docs/PRD.md +1 -1
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/pyproject.toml +1 -1
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_mapping_vci_detection.py +58 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_postconditions.py +133 -1
- arango_query_core-0.2.3/tests/test_upstream_markers.py +173 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/.github/workflows/ci.yml +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/.github/workflows/publish.yml +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/.gitignore +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/LICENSE +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/README.md +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/__init__.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/aql.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/errors.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/exec.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/extensions.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/__init__.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/fewshot.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/grounding.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/pathindex.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/providers.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/seams.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/nl/synthbank.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/owl_rdflib.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/owl_turtle.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/arango_query_core/py.typed +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/fixtures/mappings/pg.owl.ttl +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_mapping_helpers.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_mapping_resolver_normalization.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_engine.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_fewshot.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_grounding.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_pathindex.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_providers.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_nl_synthbank.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_owl_rdflib.py +0 -0
- {arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_owl_turtle_roundtrip.py +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: When a library we own lacks something or gets it wrong, fix it in the library or file it there; a workaround in the consumer is temporary, marked and tracked
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Fix Upstream, Don't Fork
|
|
7
|
+
|
|
8
|
+
> **Scope:** Any code that depends on a library we own — arango-sparql-py,
|
|
9
|
+
> arango-query-core, r2g / r2g-arango, arango-schema-analyzer, arango-cypher-py,
|
|
10
|
+
> arango-entity-resolution, or any other library the team maintains. Every one of
|
|
11
|
+
> these is also a consumer of the others, so this applies inside the libraries too.
|
|
12
|
+
|
|
13
|
+
> **"We own the library. A gap in it is an issue there, not a fork here."**
|
|
14
|
+
|
|
15
|
+
Written after a 2026-10-15 finding in contextual-data-fabric: its English→SPARQL
|
|
16
|
+
translator was never the library's. In July, arango-sparql-py could only check a
|
|
17
|
+
generated query against one schema, so the consumer took the library's model client
|
|
18
|
+
and wrote its own prompt, check and repair loop around it. That was a reasonable
|
|
19
|
+
call — but no library issue was filed and nothing in the code said it was
|
|
20
|
+
temporary. When the shared engine gained the seam the consumer needed, nobody
|
|
21
|
+
noticed. Three months later there were three implementations, the design record
|
|
22
|
+
still said the engine was reused, and the library's docstring named a function the
|
|
23
|
+
consumer never imported.
|
|
24
|
+
|
|
25
|
+
The same audit found the pattern again around arango-entity-resolution: a consumer
|
|
26
|
+
guard that copies the resolver's checks line for line, configuration defects worked
|
|
27
|
+
around in a comment instead of filed, and a service that declares the library as a
|
|
28
|
+
dependency, says in its docstring that it runs the library's pipeline, and imports
|
|
29
|
+
none of it.
|
|
30
|
+
|
|
31
|
+
## When an owned library falls short
|
|
32
|
+
|
|
33
|
+
| Do, in this order | Not |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| 1. **Check it isn't already there** — `main`, open branches, and the other org's copy | Assume the version you have pinned is the latest |
|
|
36
|
+
| 2. **Fix it in the library** — we own it; open the pull request there | Patch around it here because this is the repo you have open |
|
|
37
|
+
| 3. **Or file it there** — a bug or feature request on the repo that leads, saying what the consumer needs and why | File it on the mirror, or mention it only in this repo's pull request |
|
|
38
|
+
| 4. **Only if this repo cannot wait** for a library release and a pin bump — a temporary workaround, held to the rules below | A permanent workaround nobody can find |
|
|
39
|
+
|
|
40
|
+
**Which repo leads.** Most of these libraries exist under two owners, and which one
|
|
41
|
+
leads has changed during 2026. Do not trust the remotes of your clone — a fresh
|
|
42
|
+
clone has only the URL you were handed. The repository with recent pull requests is
|
|
43
|
+
the one that leads:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
gh pr list --repo <owner>/<name> --state all --limit 5
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run it against both candidates. If neither has recent pull requests, ask.
|
|
50
|
+
|
|
51
|
+
## A workaround that is allowed
|
|
52
|
+
|
|
53
|
+
- **Behind the seam the library would provide**, so removing it touches one place:
|
|
54
|
+
an adapter, or a wrapper with the library's own shape.
|
|
55
|
+
- **Marked where it lives**, on one line at the code that works around the library:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# upstream: <org>/arango-sparql-py#<n> — remove when the engine accepts a caller's validator
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Where a repo has `tests/test_upstream_markers.py`, that test holds the format: an
|
|
62
|
+
`owner/repo#number` issue a person can open, and a `remove when` condition someone
|
|
63
|
+
can check. The shared-memory installers place this rule but not the test, so a
|
|
64
|
+
repo without it copies it from arango-shared-memory
|
|
65
|
+
`templates/tests/test_upstream_markers.py`.
|
|
66
|
+
- **Listed in this repo's known-debt issue**, if it keeps one, with the same link.
|
|
67
|
+
- **Removed in the pin bump that ships the fix**, not in a later cleanup: when you
|
|
68
|
+
bump a pin, search for `upstream:` markers that name that library.
|
|
69
|
+
|
|
70
|
+
## What counts as a fork
|
|
71
|
+
|
|
72
|
+
| Fork | Why it costs |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| Taking a library's low-level piece (a client, a parser) and writing your own version of its main entry point around it | Fixes and new capabilities land in the library's version and never reach yours |
|
|
75
|
+
| Copying library code into the consumer | The two copies drift |
|
|
76
|
+
| Patching the library at runtime | The next pin bump breaks it without a word |
|
|
77
|
+
| Reaching past the library's public API into its internals | Internals move without a release note |
|
|
78
|
+
| Defining a public function or class with the same name and job as one the library exports | Readers take one for the other |
|
|
79
|
+
| Declaring a library as a dependency and reimplementing what you would have called | The dependency says one thing and the code does another |
|
|
80
|
+
| Holding a pin back to dodge a bug, with no issue | Nobody knows when it can move |
|
|
81
|
+
|
|
82
|
+
A fork that is deliberate and permanent — the consumer needs something the library
|
|
83
|
+
should not hold — is a **design record (ADR) in the consumer** plus an **issue in the
|
|
84
|
+
library** for the seam that would let the two share the rest. It is never a silent
|
|
85
|
+
decision.
|
|
86
|
+
|
|
87
|
+
## The other side: claims about the other repo
|
|
88
|
+
|
|
89
|
+
A library that says a consumer uses it ("this is what the fabric calls") must be able
|
|
90
|
+
to point at the consumer's import. A consumer that says it uses a library ("runs the
|
|
91
|
+
entity-resolution pipeline") must import it. If either cannot, the claim is a plan,
|
|
92
|
+
and it is written as one.
|
|
93
|
+
|
|
94
|
+
## Anti-Patterns
|
|
95
|
+
|
|
96
|
+
| Anti-Pattern | Consequence |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| "The library can't do X, so I'll write X here" | A fork nobody tracks |
|
|
99
|
+
| A workaround with no `upstream:` marker | Nobody removes it when the fix ships |
|
|
100
|
+
| An issue filed in the consumer instead of the library | The library's maintainers never see it |
|
|
101
|
+
| A library defect documented only in a config comment | The next consumer hits it too |
|
|
102
|
+
| "It's our library, I'll fix it there later" | Later is after the third implementation |
|
|
103
|
+
|
|
104
|
+
**We own the library: fix it there, or say where it will be fixed.**
|
|
@@ -6,6 +6,47 @@ exact versions (contextual-data-fabric CC-9) and re-run their golden /
|
|
|
6
6
|
eval suites on every bump — pre-1.0, **minor** bumps may break the API
|
|
7
7
|
and this file must say so explicitly.
|
|
8
8
|
|
|
9
|
+
## [0.2.3] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `PostconditionViolation.soft` (default `False`): a soft violation is
|
|
14
|
+
retried like any other, but when the retry budget is spent
|
|
15
|
+
`NLQueryEngine.generate` returns the first valid candidate whose only
|
|
16
|
+
objection was soft, instead of failing closed. The candidate still passes
|
|
17
|
+
guardrails, and the violation stays on `NLResult.postcondition` (`ok` True
|
|
18
|
+
with a non-None `postcondition` marks a soft fallback). For checks such as
|
|
19
|
+
"the query returned no rows", where empty is sometimes the right answer.
|
|
20
|
+
`run_postconditions` now returns the first *hard* violation ahead of any
|
|
21
|
+
soft one, so a hard check listed after a soft one still applies. Additive;
|
|
22
|
+
existing checks are unaffected. Part of #4.
|
|
23
|
+
|
|
24
|
+
## [0.2.2] - 2026-10-21
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Traversals no longer hint an array-expanded edge index.** `resolve_indexes`
|
|
29
|
+
treated any edge index led by `_from` / `_to` as a vertex-centric index, so
|
|
30
|
+
`[_from, type, toLabels[*]]` (r2g's opt-in `LpgLayout.index_edge_labels`
|
|
31
|
+
shape) could become arango-cypher-py's traversal `indexHint`. A persistent
|
|
32
|
+
index stores one entry per array element and a traversal scans all of them,
|
|
33
|
+
so the hinted traversal returned each edge once per label -- inflated counts,
|
|
34
|
+
no error (measured on ArangoDB 3.12.9: 20x the rows at one hop, 400x at two).
|
|
35
|
+
Such an index is now never `vci`, even with an explicit `vci: true` flag. With
|
|
36
|
+
only that index, arango-cypher-py reports a missing VCI instead of hinting it;
|
|
37
|
+
next to a flat `[_from, type]` index, the flat one is hinted, whatever the
|
|
38
|
+
order.
|
|
39
|
+
- **The schema analyzer's verdict is honored.** When a relationship mapping
|
|
40
|
+
carries arangodb-schema-analyzer's `vci.indexLevel.indexes[].traversalSafe:
|
|
41
|
+
false` (analyzer 0.15.2+), that index is not traversal-safe, whatever its
|
|
42
|
+
shape. Older exports carry no verdict; the local array-expansion check still
|
|
43
|
+
applies to them.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `IndexInfo.traversal_safe` (default `True`): False when a traversal must not
|
|
48
|
+
use the index. Additive; existing callers are unaffected.
|
|
49
|
+
|
|
9
50
|
## [0.2.1] - 2026-10-16
|
|
10
51
|
|
|
11
52
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arango-query-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)
|
|
5
5
|
Project-URL: Repository, https://github.com/ArthurKeen/arango-query-core
|
|
6
6
|
Project-URL: Changelog, https://github.com/ArthurKeen/arango-query-core/blob/main/CHANGELOG.md
|
|
@@ -87,9 +87,49 @@ def _is_structural_vci(index_type: str, fields: tuple[str, ...]) -> bool:
|
|
|
87
87
|
return any(f not in _SYSTEM_INDEX_FIELDS for f in fields)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _has_array_expansion(fields: tuple[str, ...]) -> bool:
|
|
91
|
+
"""True when any indexed field is an array expansion (``labels[*]``).
|
|
92
|
+
|
|
93
|
+
A persistent index stores one entry per array element, and a traversal
|
|
94
|
+
scans every entry under the ``_from`` / ``_to`` prefix, so a traversal that
|
|
95
|
+
uses such an index returns each edge once per element -- inflated counts,
|
|
96
|
+
no error. The index is still correct for a pattern-match lookup
|
|
97
|
+
(``FOR e IN edges FILTER e._from == ...``), but never for a traversal hint.
|
|
98
|
+
"""
|
|
99
|
+
return any("[*]" in f for f in fields)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _analyzer_unsafe_index_fields(mapping: JsonObj) -> set[tuple[str, ...]]:
|
|
103
|
+
"""Field tuples the schema analyzer marked ``traversalSafe: false``.
|
|
104
|
+
|
|
105
|
+
arangodb-schema-analyzer (0.15.2+) records its verdict per relationship
|
|
106
|
+
under ``vci.indexLevel.indexes``; ``traversalSafe`` is present only when
|
|
107
|
+
false. Older exports carry no verdict, so absence means "no opinion" and
|
|
108
|
+
the local :func:`_has_array_expansion` check still applies.
|
|
109
|
+
"""
|
|
110
|
+
vci = mapping.get("vci")
|
|
111
|
+
index_level = vci.get("indexLevel") if isinstance(vci, dict) else None
|
|
112
|
+
indexes = index_level.get("indexes") if isinstance(index_level, dict) else None
|
|
113
|
+
if not isinstance(indexes, list):
|
|
114
|
+
return set()
|
|
115
|
+
unsafe: set[tuple[str, ...]] = set()
|
|
116
|
+
for entry in indexes:
|
|
117
|
+
if not isinstance(entry, dict) or entry.get("traversalSafe") is not False:
|
|
118
|
+
continue
|
|
119
|
+
fields = entry.get("fields")
|
|
120
|
+
if isinstance(fields, list):
|
|
121
|
+
unsafe.add(tuple(str(f) for f in fields))
|
|
122
|
+
return unsafe
|
|
123
|
+
|
|
124
|
+
|
|
90
125
|
@dataclass(frozen=True)
|
|
91
126
|
class IndexInfo:
|
|
92
|
-
"""Metadata for a single index on a collection.
|
|
127
|
+
"""Metadata for a single index on a collection.
|
|
128
|
+
|
|
129
|
+
``traversal_safe`` is False when a traversal must not use the index
|
|
130
|
+
(array-expanded fields, or the schema analyzer's ``traversalSafe: false``
|
|
131
|
+
verdict). Such an index is never ``vci``, whatever its shape or flags.
|
|
132
|
+
"""
|
|
93
133
|
|
|
94
134
|
type: str
|
|
95
135
|
fields: tuple[str, ...]
|
|
@@ -98,6 +138,7 @@ class IndexInfo:
|
|
|
98
138
|
name: str = ""
|
|
99
139
|
vci: bool = False
|
|
100
140
|
deduplicate: bool = False
|
|
141
|
+
traversal_safe: bool = True
|
|
101
142
|
|
|
102
143
|
|
|
103
144
|
@dataclass(frozen=True)
|
|
@@ -573,6 +614,7 @@ class MappingResolver:
|
|
|
573
614
|
if not isinstance(indexes_raw, list):
|
|
574
615
|
return []
|
|
575
616
|
|
|
617
|
+
unsafe = _analyzer_unsafe_index_fields(mapping)
|
|
576
618
|
result: list[IndexInfo] = []
|
|
577
619
|
for idx in indexes_raw:
|
|
578
620
|
if not isinstance(idx, dict):
|
|
@@ -583,6 +625,8 @@ class MappingResolver:
|
|
|
583
625
|
else:
|
|
584
626
|
continue
|
|
585
627
|
index_type = str(idx.get("type", "persistent"))
|
|
628
|
+
traversal_safe = fields not in unsafe and not _has_array_expansion(fields)
|
|
629
|
+
vci = bool(idx.get("vci", False)) or _is_structural_vci(index_type, fields)
|
|
586
630
|
result.append(
|
|
587
631
|
IndexInfo(
|
|
588
632
|
type=index_type,
|
|
@@ -590,8 +634,9 @@ class MappingResolver:
|
|
|
590
634
|
unique=bool(idx.get("unique", False)),
|
|
591
635
|
sparse=bool(idx.get("sparse", False)),
|
|
592
636
|
name=str(idx.get("name", "")),
|
|
593
|
-
vci=
|
|
637
|
+
vci=vci and traversal_safe,
|
|
594
638
|
deduplicate=bool(idx.get("deduplicate", False)),
|
|
639
|
+
traversal_safe=traversal_safe,
|
|
595
640
|
)
|
|
596
641
|
)
|
|
597
642
|
return result
|
|
@@ -39,7 +39,10 @@ class NLResult:
|
|
|
39
39
|
"""Outcome of one NL→query generation.
|
|
40
40
|
|
|
41
41
|
``query`` is empty when generation failed (``ok`` False); ``error``
|
|
42
|
-
then carries the last validation failure
|
|
42
|
+
then carries the last validation failure, postcondition violation or
|
|
43
|
+
guardrail refusal. ``ok`` True with a non-None ``postcondition`` means a
|
|
44
|
+
soft fallback: the retry budget ran out and ``query`` is the first valid
|
|
45
|
+
candidate whose only objection was soft (``PostconditionViolation.soft``).
|
|
43
46
|
Token fields accumulate across retries so cost accounting sees the
|
|
44
47
|
whole conversation, not the last attempt.
|
|
45
48
|
"""
|
|
@@ -96,6 +99,9 @@ class NLQueryEngine:
|
|
|
96
99
|
failure: ValidationResult | None = None
|
|
97
100
|
violation: PostconditionViolation | None = None
|
|
98
101
|
candidate = ""
|
|
102
|
+
# First valid candidate whose only objection was soft: what the call
|
|
103
|
+
# returns if the budget runs out without a clean candidate.
|
|
104
|
+
fallback: tuple[str, ValidationResult, PostconditionViolation] | None = None
|
|
99
105
|
|
|
100
106
|
for attempt in range(self.max_retries + 1):
|
|
101
107
|
if attempt > 0:
|
|
@@ -138,13 +144,25 @@ class NLQueryEngine:
|
|
|
138
144
|
result.postcondition = violation
|
|
139
145
|
if violation is None:
|
|
140
146
|
break
|
|
147
|
+
# Soft only if no check objected as hard (run_postconditions
|
|
148
|
+
# returns a hard violation ahead of any soft one).
|
|
149
|
+
if violation.soft and fallback is None:
|
|
150
|
+
fallback = (candidate, failure, violation)
|
|
151
|
+
else:
|
|
152
|
+
# Budget spent without a clean candidate. If one drew only a soft
|
|
153
|
+
# objection, return it — with the objection on the result — rather
|
|
154
|
+
# than refuse a query that may well be right.
|
|
155
|
+
if fallback is not None:
|
|
156
|
+
candidate, result.validation, result.postcondition = fallback
|
|
157
|
+
failure, violation = result.validation, None
|
|
141
158
|
|
|
142
159
|
if failure is None or not failure.ok:
|
|
143
160
|
result.error = failure.error if failure else "provider returned nothing"
|
|
144
161
|
return result
|
|
145
162
|
if violation is not None:
|
|
146
|
-
# Budget spent on a valid-but-semantically-rejected query
|
|
147
|
-
#
|
|
163
|
+
# Budget spent on a valid-but-semantically-rejected query with no
|
|
164
|
+
# soft-only candidate to fall back on — fail closed rather than
|
|
165
|
+
# return a plausible wrong answer.
|
|
148
166
|
result.error = f"{violation.reason} ({violation.code})"
|
|
149
167
|
return result
|
|
150
168
|
|
|
@@ -15,14 +15,19 @@ budget as parse and translate: the model is told the rule up front (via
|
|
|
15
15
|
:meth:`Postcondition.prompt_section`, rendered once into the cacheable system
|
|
16
16
|
prefix), and a violation is fed back into the next prompt exactly like a
|
|
17
17
|
validation failure — so the loop gets a chance to *correct* the query rather
|
|
18
|
-
than just reject it, and fails closed when the budget is spent
|
|
18
|
+
than just reject it, and fails closed when the budget is spent — unless a
|
|
19
|
+
candidate drew only a *soft* violation (``PostconditionViolation.soft``), in
|
|
20
|
+
which case the first such candidate is returned instead.
|
|
19
21
|
|
|
20
22
|
Semantics (mirrors ``nl2cypher``'s original, language-agnostic here):
|
|
21
23
|
|
|
22
24
|
* Postconditions run **after** the adapter's ``validate`` succeeds — a retry is
|
|
23
25
|
only ever spent on an already-valid query.
|
|
24
|
-
* The **first** violation wins; checks are not accumulated. One clear,
|
|
25
|
-
correction beats a pile of them.
|
|
26
|
+
* The **first hard** violation wins; checks are not accumulated. One clear,
|
|
27
|
+
specific correction beats a pile of them. A *soft* violation does not stop
|
|
28
|
+
the run: the remaining checks still see the query, and any hard violation
|
|
29
|
+
among them takes precedence. Only a query no check objects to as hard can
|
|
30
|
+
become a soft fallback, whatever order the checks are listed in.
|
|
26
31
|
* A check that **raises** is treated as a violation, not an error — a broken
|
|
27
32
|
postcondition must not turn a safety mechanism into an outage. Fail closed.
|
|
28
33
|
* :meth:`Postcondition.prompt_section` renders **once**; it must not vary
|
|
@@ -60,6 +65,19 @@ class PostconditionViolation:
|
|
|
60
65
|
"""How to fix it. Also fed into the retry prompt, after ``reason``."""
|
|
61
66
|
code: str = "postcondition_violation"
|
|
62
67
|
"""Stable identifier for telemetry / assertions, e.g. ``"time_windowed"``."""
|
|
68
|
+
soft: bool = False
|
|
69
|
+
"""A soft violation asks for a retry but does not make the query unusable.
|
|
70
|
+
|
|
71
|
+
Use it for an objection that is sometimes the right answer — e.g. "the query
|
|
72
|
+
returned no rows" when the honest answer may be "none". When the retry
|
|
73
|
+
budget is spent, the engine returns the first valid candidate whose only
|
|
74
|
+
objection was soft (with this violation on ``NLResult.postcondition``)
|
|
75
|
+
instead of failing closed. Hard violations (the default) still fail closed.
|
|
76
|
+
|
|
77
|
+
Only that first soft candidate is offered to the guardrails: if they refuse
|
|
78
|
+
it, the call fails closed with the guardrail's reason, even when a later
|
|
79
|
+
soft candidate would have passed — the conservative reading.
|
|
80
|
+
"""
|
|
63
81
|
|
|
64
82
|
|
|
65
83
|
@dataclass(frozen=True)
|
|
@@ -109,15 +127,22 @@ def run_postconditions(
|
|
|
109
127
|
postconditions: Sequence[Postcondition] | None,
|
|
110
128
|
context: PostconditionContext,
|
|
111
129
|
) -> PostconditionViolation | None:
|
|
112
|
-
"""Run *postconditions* in order, returning the first violation
|
|
130
|
+
"""Run *postconditions* in order, returning the first hard violation, or
|
|
131
|
+
else the first soft one.
|
|
132
|
+
|
|
133
|
+
A soft violation does not stop the run, so a hard check listed after it
|
|
134
|
+
still sees the query; otherwise a query breaking a hard rule could be
|
|
135
|
+
returned as a soft fallback.
|
|
113
136
|
|
|
114
|
-
A check that raises is treated as a violation rather than being
|
|
115
|
-
abort the translation: a broken check must not be able to turn a
|
|
116
|
-
mechanism into an outage, and failing closed is the conservative
|
|
137
|
+
A check that raises is treated as a (hard) violation rather than being
|
|
138
|
+
allowed to abort the translation: a broken check must not be able to turn a
|
|
139
|
+
safety mechanism into an outage, and failing closed is the conservative
|
|
140
|
+
reading.
|
|
117
141
|
"""
|
|
118
142
|
if not postconditions:
|
|
119
143
|
return None
|
|
120
144
|
|
|
145
|
+
first_soft: PostconditionViolation | None = None
|
|
121
146
|
for pc in postconditions:
|
|
122
147
|
code = getattr(pc, "code", pc.__class__.__name__)
|
|
123
148
|
try:
|
|
@@ -128,9 +153,13 @@ def run_postconditions(
|
|
|
128
153
|
reason=f"Postcondition {code!r} raised {type(exc).__name__}: {exc}",
|
|
129
154
|
suggested_hint="Simplify the query; the check could not evaluate it.",
|
|
130
155
|
)
|
|
131
|
-
if violation is
|
|
156
|
+
if violation is None:
|
|
157
|
+
continue
|
|
158
|
+
if not violation.soft:
|
|
132
159
|
return violation
|
|
133
|
-
|
|
160
|
+
if first_soft is None:
|
|
161
|
+
first_soft = violation
|
|
162
|
+
return first_soft
|
|
134
163
|
|
|
135
164
|
|
|
136
165
|
def postcondition_prompt_sections(
|
|
@@ -127,7 +127,7 @@ to prevent a three-way fork.
|
|
|
127
127
|
| `nl.fewshot` | `FewShotIndex` over `(question, query)` pairs (legacy corpus keys accepted); `BM25Retriever` (rank_bm25, `nl` extra) and `DenseRetriever` (sentence-transformers, `dense` extra), selected via `mode="bm25"\|"dense"\|"auto"`; `cached_few_shot_index` memoizes the expensive dense build |
|
|
128
128
|
| `nl.grounding` | Seam 6/7 retrieval: `LabelIndex`/`GroundedEntity` (entity grounding) and `PredicateIndex`/`GroundedPredicate` (predicate/schema-convention grounding, renders IRI local names not labels), sharing one token-substring scorer |
|
|
129
129
|
| `nl.pathindex` | Seam 8: `ClassPathIndex`/`ClassPath` — bounded shortest predicate paths between two seam-6/7-anchored classes over the TBox's class-connectivity graph |
|
|
130
|
-
| `nl.postconditions` | Caller-supplied semantic invariants (`Postcondition`, `PostconditionContext`, `PostconditionViolation`, `run_postconditions`) enforced through the engine's own retry budget; fails closed on exhaustion |
|
|
130
|
+
| `nl.postconditions` | Caller-supplied semantic invariants (`Postcondition`, `PostconditionContext`, `PostconditionViolation`, `run_postconditions`) enforced through the engine's own retry budget; fails closed on exhaustion, unless a valid candidate drew only a soft violation (`PostconditionViolation.soft`); the first such candidate is returned, with the violation on `NLResult.postcondition` |
|
|
131
131
|
| `nl.synthbank` | `SHAPE_CATALOG`/`ShapeTemplate` — 9 ontology-agnostic compositional query-shape templates for synthetic few-shot bank generation |
|
|
132
132
|
| `nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams: (1) grammar prompt section, (2) few-shot corpus, (3) syntax validator, (4) repair rules keyed on validator errors, (5) guardrail checks, (6) entity grounding index, (7) predicate grounding index, (8) relationship-path grounding index. Plus `ValidationResult`, `GuardrailVerdict` |
|
|
133
133
|
|
|
@@ -86,3 +86,61 @@ class TestResolverStructuralVci:
|
|
|
86
86
|
def test_no_indexes_means_no_vci(self) -> None:
|
|
87
87
|
b = _bundle_with_edge_indexes([])
|
|
88
88
|
assert MappingResolver(b).has_vci("REL") is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestArrayExpandedIndexIsNotTraversalSafe:
|
|
92
|
+
"""An edge index with an array-expanded field must never be hinted in a traversal.
|
|
93
|
+
|
|
94
|
+
A persistent index stores one entry per array element and a traversal scans
|
|
95
|
+
every entry under the _from/_to prefix, so ``[_from, type, toLabels[*]]``
|
|
96
|
+
returns each edge once per label (measured on ArangoDB 3.12.9: 20 labels
|
|
97
|
+
per edge -> 20x the rows at one hop, 400x at two). r2g provisions this shape
|
|
98
|
+
when ``LpgLayout.index_edge_labels`` is on.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def test_array_expanded_index_is_not_vci(self) -> None:
|
|
102
|
+
b = _bundle_with_edge_indexes(
|
|
103
|
+
[{"type": "persistent", "fields": ["_from", "type", "toLabels[*]"], "name": "lbl"}]
|
|
104
|
+
)
|
|
105
|
+
r = MappingResolver(b)
|
|
106
|
+
(idx,) = r.resolve_indexes("REL")
|
|
107
|
+
assert idx.traversal_safe is False
|
|
108
|
+
assert idx.vci is False
|
|
109
|
+
assert r.has_vci("REL") is False
|
|
110
|
+
|
|
111
|
+
def test_explicit_flag_does_not_override_array_expansion(self) -> None:
|
|
112
|
+
b = _bundle_with_edge_indexes(
|
|
113
|
+
[{"type": "persistent", "fields": ["_from", "toLabels[*]"], "name": "lbl", "vci": True}]
|
|
114
|
+
)
|
|
115
|
+
assert MappingResolver(b).has_vci("REL") is False
|
|
116
|
+
|
|
117
|
+
def test_safe_index_still_vci_alongside_risky_one(self) -> None:
|
|
118
|
+
b = _bundle_with_edge_indexes(
|
|
119
|
+
[
|
|
120
|
+
{"type": "persistent", "fields": ["_from", "type"], "name": "flat"},
|
|
121
|
+
{"type": "persistent", "fields": ["_from", "type", "toLabels[*]"], "name": "lbl"},
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
by_name = {i.name: i for i in MappingResolver(b).resolve_indexes("REL")}
|
|
125
|
+
assert by_name["flat"].vci is True and by_name["flat"].traversal_safe is True
|
|
126
|
+
assert by_name["lbl"].vci is False
|
|
127
|
+
|
|
128
|
+
def test_analyzer_verdict_is_honored(self) -> None:
|
|
129
|
+
# The schema analyzer's traversalSafe: false wins even for a shape the
|
|
130
|
+
# local check would accept -- its verdict is the source of truth.
|
|
131
|
+
b = _bundle_with_edge_indexes([{"type": "persistent", "fields": ["_from", "type"], "name": "flat"}])
|
|
132
|
+
b.physical_mapping["relationships"]["REL"]["vci"] = {
|
|
133
|
+
"indexLevel": {"indexes": [{"fields": ["_from", "type"], "traversalSafe": False}]}
|
|
134
|
+
}
|
|
135
|
+
(idx,) = MappingResolver(b).resolve_indexes("REL")
|
|
136
|
+
assert idx.traversal_safe is False and idx.vci is False
|
|
137
|
+
|
|
138
|
+
def test_analyzer_block_without_verdict_keeps_safe_index(self) -> None:
|
|
139
|
+
# Exports before analyzer 0.15.2 have a vci block but no traversalSafe:
|
|
140
|
+
# absence is "no opinion", not "unsafe".
|
|
141
|
+
b = _bundle_with_edge_indexes([{"type": "persistent", "fields": ["_from", "type"], "name": "flat"}])
|
|
142
|
+
b.physical_mapping["relationships"]["REL"]["vci"] = {
|
|
143
|
+
"indexLevel": {"indexes": [{"fields": ["_from", "type"], "discriminatorFields": ["type"]}]}
|
|
144
|
+
}
|
|
145
|
+
(idx,) = MappingResolver(b).resolve_indexes("REL")
|
|
146
|
+
assert idx.traversal_safe is True and idx.vci is True
|
|
@@ -4,7 +4,9 @@ Covers the semantics the feature promises: postconditions run only on valid
|
|
|
4
4
|
queries, share the retry budget, feed their correction back into the next
|
|
5
5
|
prompt, fail closed on exhaustion, first-violation-wins, a raising check is a
|
|
6
6
|
violation (not an outage), the rule is announced once in the system prompt,
|
|
7
|
-
and the guardrail gate still runs after.
|
|
7
|
+
and the guardrail gate still runs after. Soft violations: retried the same way,
|
|
8
|
+
returned as a fallback on exhaustion, never ahead of a hard violation whatever
|
|
9
|
+
the check order. All with fakes — no network.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
from __future__ import annotations
|
|
@@ -208,3 +210,133 @@ def test_run_postconditions_helper_empty_is_none():
|
|
|
208
210
|
ctx = PostconditionContext(schema_summary="", question="q", attempt=0)
|
|
209
211
|
assert run_postconditions("SELECT 1", None, ctx) is None
|
|
210
212
|
assert run_postconditions("SELECT 1", [], ctx) is None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class NonEmpty:
|
|
216
|
+
"""Soft stand-in for "the query returned no rows": a candidate without
|
|
217
|
+
'WHERE' is treated as empty. Sometimes empty is the right answer, so the
|
|
218
|
+
objection is soft."""
|
|
219
|
+
|
|
220
|
+
code = "non_empty"
|
|
221
|
+
|
|
222
|
+
def prompt_section(self) -> str:
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
def check(self, query: str, *, context: PostconditionContext):
|
|
226
|
+
if "WHERE" in query:
|
|
227
|
+
return None
|
|
228
|
+
return PostconditionViolation(
|
|
229
|
+
code=self.code,
|
|
230
|
+
reason="The query returned no rows.",
|
|
231
|
+
suggested_hint="Check literals and labels.",
|
|
232
|
+
soft=True,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_soft_violation_is_retried_like_a_hard_one():
|
|
237
|
+
eng = _engine(["SELECT *", "SELECT * WHERE {}"], [NonEmpty()])
|
|
238
|
+
r = eng.generate("q")
|
|
239
|
+
assert r.ok is True and r.retries == 1
|
|
240
|
+
assert r.query == "SELECT * WHERE {}"
|
|
241
|
+
assert r.postcondition is None
|
|
242
|
+
assert "Check literals and labels." in eng.provider.calls[1][1]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_soft_exhaustion_returns_first_soft_candidate():
|
|
246
|
+
eng = _engine(["SELECT 1", "SELECT 2", "SELECT 3"], [NonEmpty()], max_retries=2)
|
|
247
|
+
r = eng.generate("q")
|
|
248
|
+
assert r.ok is True and r.error == ""
|
|
249
|
+
assert r.retries == 2 # the whole budget was spent first
|
|
250
|
+
assert r.query == "SELECT 1" # the first candidate, not the last
|
|
251
|
+
assert r.postcondition is not None and r.postcondition.soft
|
|
252
|
+
assert r.postcondition.code == "non_empty"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_soft_fallback_survives_a_later_invalid_candidate():
|
|
256
|
+
eng = _engine(["SELECT 1", "nonsense"], [NonEmpty()], max_retries=1)
|
|
257
|
+
r = eng.generate("q")
|
|
258
|
+
assert r.ok is True and r.query == "SELECT 1"
|
|
259
|
+
assert r.validation is not None and r.validation.ok
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_soft_fallback_survives_a_later_hard_violation():
|
|
263
|
+
# Soft-only first candidate (has LIMIT, no WHERE); the second breaks the
|
|
264
|
+
# hard invariant. The soft candidate is the one worth returning.
|
|
265
|
+
eng = _engine(["SELECT * LIMIT 1", "SELECT *"], [NeedsLimit(), NonEmpty()], max_retries=1)
|
|
266
|
+
r = eng.generate("q")
|
|
267
|
+
assert r.ok is True and r.query == "SELECT * LIMIT 1"
|
|
268
|
+
assert r.postcondition is not None and r.postcondition.code == "non_empty"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_hard_violation_still_fails_closed_beside_a_soft_check():
|
|
272
|
+
# Every candidate breaks the hard rule first (first violation wins), so
|
|
273
|
+
# none is ever soft-only: no fallback, fail closed as before.
|
|
274
|
+
eng = _engine(["SELECT *"], [NeedsLimit(), NonEmpty()], max_retries=2)
|
|
275
|
+
r = eng.generate("q")
|
|
276
|
+
assert r.ok is False and r.query == ""
|
|
277
|
+
assert r.postcondition is not None and r.postcondition.code == "needs_limit"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_soft_fallback_still_passes_the_guardrail_gate():
|
|
281
|
+
eng = _engine(["SELECT 1"], [NonEmpty()], allow=False, max_retries=1)
|
|
282
|
+
r = eng.generate("q")
|
|
283
|
+
assert r.ok is False and r.query == ""
|
|
284
|
+
assert "tenant scope" in r.error
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_violation_is_hard_by_default():
|
|
288
|
+
assert PostconditionViolation(reason="r", suggested_hint="h").soft is False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_hard_check_after_a_soft_one_still_applies():
|
|
292
|
+
# Regression: the soft check used to short-circuit the run, so a query
|
|
293
|
+
# breaking the hard rule listed after it came back ok as a soft fallback.
|
|
294
|
+
eng = _engine(["SELECT *"], [NonEmpty(), NeedsLimit()], max_retries=2)
|
|
295
|
+
r = eng.generate("q")
|
|
296
|
+
assert r.ok is False and r.query == ""
|
|
297
|
+
assert r.postcondition is not None and r.postcondition.code == "needs_limit"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_soft_then_hard_reports_the_hard_correction():
|
|
301
|
+
eng = _engine(["SELECT *", "SELECT * LIMIT 1 WHERE {}"], [NonEmpty(), NeedsLimit()])
|
|
302
|
+
r = eng.generate("q")
|
|
303
|
+
assert r.ok is True and r.retries == 1
|
|
304
|
+
assert "Add LIMIT 100." in eng.provider.calls[1][1] # the hard hint, not the soft one
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_raising_check_after_a_soft_one_still_fails_closed():
|
|
308
|
+
class Boom:
|
|
309
|
+
code = "boom"
|
|
310
|
+
|
|
311
|
+
def prompt_section(self):
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
def check(self, query, *, context):
|
|
315
|
+
raise ValueError("kaboom")
|
|
316
|
+
|
|
317
|
+
eng = _engine(["SELECT 1"], [NonEmpty(), Boom()], max_retries=1)
|
|
318
|
+
r = eng.generate("q")
|
|
319
|
+
assert r.ok is False and r.query == ""
|
|
320
|
+
assert r.postcondition is not None and r.postcondition.code == "boom_error"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_soft_fallback_with_no_retries():
|
|
324
|
+
eng = _engine(["SELECT 1"], [NonEmpty()], max_retries=0)
|
|
325
|
+
r = eng.generate("q")
|
|
326
|
+
assert r.ok is True and r.retries == 0 and r.query == "SELECT 1"
|
|
327
|
+
assert r.postcondition is not None and r.postcondition.soft
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_clean_last_candidate_beats_the_soft_fallback():
|
|
331
|
+
eng = _engine(["SELECT 1 LIMIT 1", "SELECT * LIMIT 1 WHERE {}"], [NeedsLimit(), NonEmpty()])
|
|
332
|
+
r = eng.generate("q")
|
|
333
|
+
assert r.ok is True and r.query == "SELECT * LIMIT 1 WHERE {}"
|
|
334
|
+
assert r.postcondition is None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_run_postconditions_prefers_hard_over_earlier_soft():
|
|
338
|
+
ctx = PostconditionContext(schema_summary="", question="q", attempt=0)
|
|
339
|
+
v = run_postconditions("SELECT *", [NonEmpty(), NeedsLimit()], ctx)
|
|
340
|
+
assert v is not None and v.code == "needs_limit" and not v.soft
|
|
341
|
+
v = run_postconditions("SELECT * LIMIT 1", [NonEmpty(), NeedsLimit()], ctx)
|
|
342
|
+
assert v is not None and v.code == "non_empty" and v.soft
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""A workaround for a library we own names its upstream issue
|
|
2
|
+
(`.cursor/rules/fix-upstream-dont-fork.mdc`).
|
|
3
|
+
|
|
4
|
+
A gap in an owned library is fixed or filed in that library. When this repo
|
|
5
|
+
cannot wait for the release, the workaround is allowed only while it is marked
|
|
6
|
+
where it lives::
|
|
7
|
+
|
|
8
|
+
# upstream: <owner>/<repo>#<n> — remove when <condition>
|
|
9
|
+
|
|
10
|
+
so the pin bump that ships the fix can find it. This holds the marker's shape in
|
|
11
|
+
every scanned file: an issue a person can open, and a condition someone can
|
|
12
|
+
check. Whether that issue is still open needs the network, so it is not checked
|
|
13
|
+
here.
|
|
14
|
+
|
|
15
|
+
Shared source: arango-shared-memory ``templates/tests/test_upstream_markers.py``.
|
|
16
|
+
Copy changes back there so every repository keeps the same check.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
28
|
+
RULE = ROOT / ".cursor" / "rules" / "fix-upstream-dont-fork.mdc"
|
|
29
|
+
|
|
30
|
+
#: Files whose comments start with ``#``.
|
|
31
|
+
SCANNED_SUFFIXES = frozenset({".py", ".sh", ".yml", ".yaml", ".toml", ".cfg"})
|
|
32
|
+
SCANNED_NAMES = frozenset({"Makefile", "Dockerfile"})
|
|
33
|
+
#: Directories that hold no code of ours. Hidden directories are skipped too,
|
|
34
|
+
#: except ``.github``, where CI steps can work around a library.
|
|
35
|
+
SKIPPED_DIRS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"__pycache__",
|
|
38
|
+
"build",
|
|
39
|
+
"dist",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"site-packages",
|
|
42
|
+
"venv",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
KEPT_HIDDEN_DIRS = frozenset({".github"})
|
|
46
|
+
|
|
47
|
+
#: Any comment that claims to be a marker, however it is written.
|
|
48
|
+
MARKER = re.compile(r"#\s*upstream\s*:(?P<rest>.*)$", re.IGNORECASE)
|
|
49
|
+
#: What a marker must say: an issue (owner/repo#n), then when to remove it.
|
|
50
|
+
WELL_FORMED = re.compile(
|
|
51
|
+
r"^\s*(?P<repo>[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*)#(?P<issue>[1-9]\d*)"
|
|
52
|
+
r"\s+(?:—|–|--?)\s+remove when\s+(?P<when>\S.*?)\s*$"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def marker_problems(text: str, where: str) -> list[str]:
|
|
57
|
+
"""Each malformed ``upstream:`` marker in ``text``, as ``where:line: why``."""
|
|
58
|
+
problems = []
|
|
59
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
60
|
+
found = MARKER.search(line)
|
|
61
|
+
if found is None:
|
|
62
|
+
continue
|
|
63
|
+
if WELL_FORMED.match(found.group("rest")) is None:
|
|
64
|
+
problems.append(
|
|
65
|
+
f"{where}:{number}: {line.strip()!r} — write it as "
|
|
66
|
+
"'# upstream: <owner>/<repo>#<n> — remove when <condition>'"
|
|
67
|
+
)
|
|
68
|
+
return problems
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _skipped_dir(name: str) -> bool:
|
|
72
|
+
if name in SKIPPED_DIRS or name.endswith(".egg-info"):
|
|
73
|
+
return True
|
|
74
|
+
return name.startswith(".") and name not in KEPT_HIDDEN_DIRS
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _skipped(relative: Path) -> bool:
|
|
78
|
+
return any(_skipped_dir(part) for part in relative.parts[:-1])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _scanned_files() -> list[Path]:
|
|
82
|
+
files = []
|
|
83
|
+
# os.walk with pruning, so .git, virtualenvs and node_modules are never entered.
|
|
84
|
+
for current, dirs, names in os.walk(ROOT):
|
|
85
|
+
dirs[:] = sorted(name for name in dirs if not _skipped_dir(name))
|
|
86
|
+
for name in sorted(names):
|
|
87
|
+
path = Path(current) / name
|
|
88
|
+
if path.suffix in SCANNED_SUFFIXES or name in SCANNED_NAMES:
|
|
89
|
+
files.append(path)
|
|
90
|
+
# This file states malformed markers on purpose, to prove they are caught.
|
|
91
|
+
return [path for path in files if path.resolve() != Path(__file__).resolve()]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# -- the tree --------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_every_upstream_marker_names_an_issue_and_when_to_remove_it() -> None:
|
|
98
|
+
problems = []
|
|
99
|
+
for path in _scanned_files():
|
|
100
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
101
|
+
problems.extend(marker_problems(text, str(path.relative_to(ROOT))))
|
|
102
|
+
assert not problems, "malformed upstream markers:\n " + "\n ".join(problems)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_the_scan_reaches_this_repositorys_code() -> None:
|
|
106
|
+
"""A scan that silently finds nothing would pass forever."""
|
|
107
|
+
scanned = {path.relative_to(ROOT).parts[0] for path in _scanned_files()}
|
|
108
|
+
assert "tests" in scanned
|
|
109
|
+
assert len(_scanned_files()) > 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize(
|
|
113
|
+
("relative", "skipped"),
|
|
114
|
+
[
|
|
115
|
+
("src/pkg/module.py", False),
|
|
116
|
+
(".github/workflows/ci.yml", False),
|
|
117
|
+
(".venv/lib/python3.11/site-packages/x.py", True),
|
|
118
|
+
("pkg.egg-info/PKG-INFO.py", True),
|
|
119
|
+
("frontend/node_modules/x/setup.py", True),
|
|
120
|
+
(".claude/hooks/x.py", True),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
def test_the_scan_skips_only_what_is_not_ours(relative: str, skipped: bool) -> None:
|
|
124
|
+
assert _skipped(Path(relative)) is skipped
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -- the check itself ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize(
|
|
131
|
+
"line",
|
|
132
|
+
[
|
|
133
|
+
"# upstream: ArthurKeen/arango-sparql-py#123 — remove when the engine takes a validator",
|
|
134
|
+
"x = build() # upstream: arango-solutions-org/r2g-arango#7 -- remove when 0.7 ships",
|
|
135
|
+
" # upstream: org/arango-query-core#42 – remove when LabelIndex accepts aliases",
|
|
136
|
+
" run: make gate # upstream: org/arango-cypher#9 - remove when the pin moves",
|
|
137
|
+
],
|
|
138
|
+
)
|
|
139
|
+
def test_a_well_formed_marker_passes(line: str) -> None:
|
|
140
|
+
assert marker_problems(line, "example") == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.mark.parametrize(
|
|
144
|
+
("line", "missing"),
|
|
145
|
+
[
|
|
146
|
+
("# upstream: TODO file an issue", "no issue at all"),
|
|
147
|
+
("# upstream: arango-sparql-py#12 — remove when fixed", "no owner"),
|
|
148
|
+
("# upstream: org/arango-sparql-py — remove when fixed", "no issue number"),
|
|
149
|
+
("# upstream: org/arango-sparql-py#0 — remove when fixed", "issue zero"),
|
|
150
|
+
("# upstream: org/arango-sparql-py#12", "no removal condition"),
|
|
151
|
+
("# upstream: org/arango-sparql-py#12 — remove when ", "an empty condition"),
|
|
152
|
+
("# Upstream: org/arango-sparql-py#12 remove when fixed", "no separator"),
|
|
153
|
+
("#upstream:https://github.com/org/arango-sparql-py/issues/12", "a URL, not owner/repo#n"),
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
def test_a_malformed_marker_is_named_with_where_it_is(line: str, missing: str) -> None:
|
|
157
|
+
problems = marker_problems(f"first line\n{line}\n", "src/example.py")
|
|
158
|
+
assert len(problems) == 1, f"{missing}: {problems}"
|
|
159
|
+
assert problems[0].startswith("src/example.py:2: ")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_a_line_that_only_mentions_upstream_is_not_a_marker() -> None:
|
|
163
|
+
text = "# fixed upstream in 0.6.1\n# the upstream repository leads\nupstream = 'x'\n"
|
|
164
|
+
assert marker_problems(text, "example") == []
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -- the rule says what this test holds ------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_the_rule_names_this_test_and_the_marker_format() -> None:
|
|
171
|
+
rule = RULE.read_text(encoding="utf-8")
|
|
172
|
+
assert "tests/test_upstream_markers.py" in rule
|
|
173
|
+
assert "# upstream: <org>/" in rule and "remove when" in rule
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arango_query_core-0.2.1 → arango_query_core-0.2.3}/tests/test_mapping_resolver_normalization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|