ordel-engine 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.
- ordel_engine-0.1.0/.gitignore +175 -0
- ordel_engine-0.1.0/LICENSE +201 -0
- ordel_engine-0.1.0/PKG-INFO +35 -0
- ordel_engine-0.1.0/README.pypi.md +17 -0
- ordel_engine-0.1.0/ordel_engine/__init__.py +44 -0
- ordel_engine-0.1.0/ordel_engine/capture.py +110 -0
- ordel_engine-0.1.0/ordel_engine/element_fingerprint.py +387 -0
- ordel_engine-0.1.0/ordel_engine/heal.py +84 -0
- ordel_engine-0.1.0/ordel_engine/py.typed +0 -0
- ordel_engine-0.1.0/ordel_engine/similarity.py +159 -0
- ordel_engine-0.1.0/pyproject.toml +41 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
*.egg
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Runtime
|
|
14
|
+
.ordel/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.vscode/
|
|
18
|
+
.idea/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# OS
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
|
|
26
|
+
# Environment
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
!.env.example
|
|
30
|
+
!.env.prod.example
|
|
31
|
+
|
|
32
|
+
# Terraform
|
|
33
|
+
**/.terraform/*
|
|
34
|
+
*.tfstate
|
|
35
|
+
*.tfstate.*
|
|
36
|
+
*.tfvars
|
|
37
|
+
!*.tfvars.example
|
|
38
|
+
tfplan
|
|
39
|
+
*.tfplan
|
|
40
|
+
crash.log
|
|
41
|
+
crash.*.log
|
|
42
|
+
|
|
43
|
+
# Stray root-level node_modules (e.g. an npm install run from the repo root,
|
|
44
|
+
# or the rrweb-dom-stream poc pulling deps up) — never committed.
|
|
45
|
+
/node_modules/
|
|
46
|
+
# Local trace-capture scratch (Langfuse/OTel dumps) — throwaway.
|
|
47
|
+
/scratchpad_traces/
|
|
48
|
+
|
|
49
|
+
# Node (web/)
|
|
50
|
+
web/node_modules/
|
|
51
|
+
web/.next/
|
|
52
|
+
# Stale build caches moved aside during repairs (e.g. the Kestrel→Ordel
|
|
53
|
+
# rename Turbopack fix). Local-only, safe to delete.
|
|
54
|
+
web/.next.*/
|
|
55
|
+
web/out/
|
|
56
|
+
web/.pnp
|
|
57
|
+
web/.pnp.*
|
|
58
|
+
# TypeScript incremental build cache — local-only, regenerates on each build.
|
|
59
|
+
web/tsconfig.tsbuildinfo
|
|
60
|
+
|
|
61
|
+
# Node runner (node-runner/) — local Playwright run output
|
|
62
|
+
node-runner/runs/
|
|
63
|
+
node-runner/node_modules/
|
|
64
|
+
|
|
65
|
+
# Testing
|
|
66
|
+
.coverage
|
|
67
|
+
htmlcov/
|
|
68
|
+
.pytest_cache/
|
|
69
|
+
.ruff_cache/
|
|
70
|
+
.mypy_cache/
|
|
71
|
+
test-results/
|
|
72
|
+
web/test-results/
|
|
73
|
+
web/playwright-report/
|
|
74
|
+
backend/test-results/
|
|
75
|
+
|
|
76
|
+
# Playwright
|
|
77
|
+
.playwright-cli/
|
|
78
|
+
.playwright-mcp/
|
|
79
|
+
.playwright/
|
|
80
|
+
|
|
81
|
+
# storageState files — contain live auth tokens; regenerated each run
|
|
82
|
+
web/tests/.auth/
|
|
83
|
+
web/**/.auth/
|
|
84
|
+
|
|
85
|
+
# Worktrees
|
|
86
|
+
.worktrees/
|
|
87
|
+
|
|
88
|
+
# Brainstorm sessions
|
|
89
|
+
.superpowers/
|
|
90
|
+
|
|
91
|
+
# Claude Code config
|
|
92
|
+
.claude.json
|
|
93
|
+
.claude/worktrees/
|
|
94
|
+
*.debug
|
|
95
|
+
|
|
96
|
+
# Backups (local only)
|
|
97
|
+
backups/
|
|
98
|
+
|
|
99
|
+
# Local dev notes (credentials, scratch)
|
|
100
|
+
draft-keep.txt
|
|
101
|
+
|
|
102
|
+
# Binary artifacts
|
|
103
|
+
*.docx
|
|
104
|
+
|
|
105
|
+
# Test artifacts (manual playtest screenshots + recorder session captures)
|
|
106
|
+
# Live snapshots end up next to whatever directory the tester ran from;
|
|
107
|
+
# pin specific names rather than a blanket *.png so legit UI assets in
|
|
108
|
+
# web/public/ + docs/ don't get caught.
|
|
109
|
+
/*.png
|
|
110
|
+
/web/*.png
|
|
111
|
+
/backend/*.png
|
|
112
|
+
/qa-suite/modules/_session-*.spec.ts
|
|
113
|
+
/backend/bugs.md
|
|
114
|
+
|
|
115
|
+
# Backend runtime artifact storage (POM persist / local-FS dev mirror)
|
|
116
|
+
/backend/storage/
|
|
117
|
+
|
|
118
|
+
# Archived docs (historical plans, sessions, completed milestones — not for review)
|
|
119
|
+
docs/archive/
|
|
120
|
+
|
|
121
|
+
# =============================================================
|
|
122
|
+
# Reference / experimental code (kept locally, not in repo)
|
|
123
|
+
# =============================================================
|
|
124
|
+
|
|
125
|
+
# Performance testing reference implementation (Phase 2)
|
|
126
|
+
Perf_Flow/
|
|
127
|
+
Perf_Flow.zip
|
|
128
|
+
|
|
129
|
+
# Archived v1 components (replaced by v2)
|
|
130
|
+
web/components/scripts/archived/
|
|
131
|
+
|
|
132
|
+
# Unused shared components (experimental)
|
|
133
|
+
web/components/shared/approval-batch.tsx
|
|
134
|
+
web/components/shared/coverage-ring.tsx
|
|
135
|
+
web/components/shared/inline-approval.tsx
|
|
136
|
+
web/components/shared/onboarding-tour.tsx
|
|
137
|
+
|
|
138
|
+
# Unused studio components (experimental)
|
|
139
|
+
web/components/studio/ArtifactCard.tsx
|
|
140
|
+
web/components/studio/BrowserPreview.tsx
|
|
141
|
+
web/components/studio/NewSessionModal.tsx
|
|
142
|
+
|
|
143
|
+
# Unused layout components (replaced)
|
|
144
|
+
web/components/layout/app-sidebar.tsx
|
|
145
|
+
web/components/layout/header.tsx
|
|
146
|
+
.claude/scheduled_tasks.lock
|
|
147
|
+
|
|
148
|
+
# =============================================================
|
|
149
|
+
# Per-developer tool output (regenerable, don't commit)
|
|
150
|
+
# =============================================================
|
|
151
|
+
|
|
152
|
+
# Antigravity CLI: home-dir symlinks to ~/.gemini/config/projects/*
|
|
153
|
+
.antigravitycli/
|
|
154
|
+
|
|
155
|
+
# Understand-Anything: knowledge graph cache (regenerate with /understand)
|
|
156
|
+
# Keep the .understandignore input config so all devs share the same graph scope.
|
|
157
|
+
.understand-anything/*
|
|
158
|
+
!.understand-anything/.understandignore
|
|
159
|
+
|
|
160
|
+
# Local install / runtime logs (uvicorn, npm, pip, alembic, bootstrap, etc.)
|
|
161
|
+
*.log
|
|
162
|
+
*.err
|
|
163
|
+
backend/.secrets/
|
|
164
|
+
*.pem
|
|
165
|
+
.secrets/
|
|
166
|
+
|
|
167
|
+
# Workflow/agent scratch scripts (throwaway)
|
|
168
|
+
.wf-*
|
|
169
|
+
backend/.wf-*
|
|
170
|
+
|
|
171
|
+
# Stale: web/e2e was renamed to web/tests long ago — keep the leftover out
|
|
172
|
+
web/e2e/
|
|
173
|
+
docs/conference/demo-video/
|
|
174
|
+
scratchpad-video/
|
|
175
|
+
.overmind.env
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ordel-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ordel's deterministic QA engine — the pure functional core (fingerprint matching + self-heal) that runs locally in the free CLI with no DB, no backend, no LLM.
|
|
5
|
+
Project-URL: Homepage, https://ordel.io
|
|
6
|
+
Author: Ordel
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: fingerprint,qa,selectors,self-healing,testing
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# ordel-engine
|
|
20
|
+
|
|
21
|
+
The deterministic core of the [Ordel free CLI](https://pypi.org/project/ordel-cli/): pure,
|
|
22
|
+
stdlib-only functions for **element fingerprinting** and **self-healing selectors** — no DB,
|
|
23
|
+
no backend, no LLM.
|
|
24
|
+
|
|
25
|
+
You usually don't install this directly; it's a dependency of `ordel-cli`. It's published
|
|
26
|
+
separately because it's the shared, deterministic substrate the CLI runs locally.
|
|
27
|
+
|
|
28
|
+
## What's in it
|
|
29
|
+
- **element fingerprint** — a stable, structural fingerprint of a DOM element.
|
|
30
|
+
- **similarity** — deterministic scoring between a recorded element and live candidates.
|
|
31
|
+
- **heal** — recover a broken selector by best-match, or signal `needs_agent` when ambiguous
|
|
32
|
+
(the boundary a coding agent resolves with its own LLM — the engine never calls one).
|
|
33
|
+
- **capture** — normalize a raw browser capture into fingerprintable signals.
|
|
34
|
+
|
|
35
|
+
Pure Python, zero third-party dependencies. Apache-2.0.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ordel-engine
|
|
2
|
+
|
|
3
|
+
The deterministic core of the [Ordel free CLI](https://pypi.org/project/ordel-cli/): pure,
|
|
4
|
+
stdlib-only functions for **element fingerprinting** and **self-healing selectors** — no DB,
|
|
5
|
+
no backend, no LLM.
|
|
6
|
+
|
|
7
|
+
You usually don't install this directly; it's a dependency of `ordel-cli`. It's published
|
|
8
|
+
separately because it's the shared, deterministic substrate the CLI runs locally.
|
|
9
|
+
|
|
10
|
+
## What's in it
|
|
11
|
+
- **element fingerprint** — a stable, structural fingerprint of a DOM element.
|
|
12
|
+
- **similarity** — deterministic scoring between a recorded element and live candidates.
|
|
13
|
+
- **heal** — recover a broken selector by best-match, or signal `needs_agent` when ambiguous
|
|
14
|
+
(the boundary a coding agent resolves with its own LLM — the engine never calls one).
|
|
15
|
+
- **capture** — normalize a raw browser capture into fingerprintable signals.
|
|
16
|
+
|
|
17
|
+
Pure Python, zero third-party dependencies. Apache-2.0.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Ordel engine — the pure, deterministic QA core for the free CLI.
|
|
2
|
+
|
|
3
|
+
No DB, no auth, no network, no LLM. Stdlib-only. This is the functional core:
|
|
4
|
+
element fingerprinting + self-healing selectors, called by the CLI's shell
|
|
5
|
+
(which owns all I/O to the local ``.ordel/`` store).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ordel_engine.capture import (
|
|
11
|
+
fingerprint_from_dict,
|
|
12
|
+
fingerprint_from_signals,
|
|
13
|
+
id_is_dynamic,
|
|
14
|
+
)
|
|
15
|
+
from ordel_engine.element_fingerprint import (
|
|
16
|
+
ElementFingerprint,
|
|
17
|
+
MatchResult,
|
|
18
|
+
Verdict,
|
|
19
|
+
match,
|
|
20
|
+
score,
|
|
21
|
+
)
|
|
22
|
+
from ordel_engine.heal import (
|
|
23
|
+
AmbiguityError,
|
|
24
|
+
HealError,
|
|
25
|
+
HealFailedError,
|
|
26
|
+
HealOutcome,
|
|
27
|
+
heal_by_fingerprint,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ElementFingerprint",
|
|
32
|
+
"MatchResult",
|
|
33
|
+
"Verdict",
|
|
34
|
+
"match",
|
|
35
|
+
"score",
|
|
36
|
+
"fingerprint_from_signals",
|
|
37
|
+
"fingerprint_from_dict",
|
|
38
|
+
"id_is_dynamic",
|
|
39
|
+
"heal_by_fingerprint",
|
|
40
|
+
"HealOutcome",
|
|
41
|
+
"HealError",
|
|
42
|
+
"AmbiguityError",
|
|
43
|
+
"HealFailedError",
|
|
44
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""DOM signals → :class:`ElementFingerprint`.
|
|
2
|
+
|
|
3
|
+
The bridge between a browser-captured element and the deterministic matcher. Input is
|
|
4
|
+
the JSON an ``ElementSignals`` capture emits (node-runner
|
|
5
|
+
``recorder-service/locator-inference.ts`` — Node is the SOLE canonicalization
|
|
6
|
+
authority, so the ``*_canon`` fields are consumed VERBATIM here, never recomputed).
|
|
7
|
+
|
|
8
|
+
Mirrors the backend's canonical constructor
|
|
9
|
+
(``ordel.core.services.observed_graph_service._element_fp``) — in particular its
|
|
10
|
+
``id_is_dynamic`` heuristic: an id containing a digit is treated as dynamic (so it is
|
|
11
|
+
excluded from the identity hash). If either source-of-truth changes, update this too;
|
|
12
|
+
``tests/test_capture.py`` pins the mapping.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ordel_engine.element_fingerprint import ElementFingerprint
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _s(v: Any) -> str:
|
|
23
|
+
return "" if v is None else str(v)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _safe_bbox(v: Any) -> tuple[float, float, float, float] | None:
|
|
27
|
+
"""A bbox is trusted only as a 4-tuple of numbers; anything else (wrong length,
|
|
28
|
+
non-list, non-numeric) is dropped rather than crashing the geometry scorer (which
|
|
29
|
+
reads bbox[3]) or the float coercion."""
|
|
30
|
+
if isinstance(v, (list, tuple)) and len(v) == 4:
|
|
31
|
+
try:
|
|
32
|
+
return (float(v[0]), float(v[1]), float(v[2]), float(v[3]))
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _class_tokens(v: Any) -> frozenset[str]:
|
|
39
|
+
"""Coerce class tokens to a frozenset of lowercased strings. Tolerates non-string
|
|
40
|
+
elements (a stray number in the list would otherwise crash on ``.lower()``)."""
|
|
41
|
+
return frozenset(str(c).lower() for c in (v or []))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fingerprint_from_dict(d: dict[str, Any]) -> ElementFingerprint:
|
|
45
|
+
"""Build an :class:`ElementFingerprint` from an ElementFingerprint-FIELD dict — the
|
|
46
|
+
shape persisted in ``.ordel/`` and the shape an agent passes to ``heal_selector``.
|
|
47
|
+
|
|
48
|
+
Tolerant by construction: unknown keys are ignored, ``class_tokens`` is coerced to a
|
|
49
|
+
frozenset, and ``bbox`` is kept only when it is a valid 4-tuple (else dropped). This
|
|
50
|
+
is the ONE safe field-dict → fingerprint path; ``fingerprint_from_signals`` is the
|
|
51
|
+
separate node-*signals* path.
|
|
52
|
+
"""
|
|
53
|
+
known = ElementFingerprint.__dataclass_fields__.keys()
|
|
54
|
+
data = {k: v for k, v in d.items() if k in known}
|
|
55
|
+
if "class_tokens" in data:
|
|
56
|
+
data["class_tokens"] = _class_tokens(data["class_tokens"])
|
|
57
|
+
data["bbox"] = _safe_bbox(data.get("bbox"))
|
|
58
|
+
return ElementFingerprint(**data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def id_is_dynamic(raw_id: str | None) -> bool:
|
|
62
|
+
"""A raw ``id`` containing any digit is treated as dynamic (framework-generated
|
|
63
|
+
like ``mui-4417`` / ``ember123``), so it is not trusted as identity.
|
|
64
|
+
|
|
65
|
+
Mirrors ``observed_graph_service._element_fp``:
|
|
66
|
+
``bool(el.el_id and any(d in el.el_id for d in _DIGIT))``. Tolerates a non-string id
|
|
67
|
+
(a numeric ``id`` would otherwise crash iterating it) by coercing to str first.
|
|
68
|
+
"""
|
|
69
|
+
if raw_id is None:
|
|
70
|
+
return False
|
|
71
|
+
return any(ch.isdigit() for ch in str(raw_id))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fingerprint_from_signals(signals: dict[str, Any]) -> ElementFingerprint:
|
|
75
|
+
"""Build an :class:`ElementFingerprint` from a node ``ElementSignals`` dict.
|
|
76
|
+
|
|
77
|
+
Canonical identity fields (``*_canon``) are taken verbatim from Node. Raw
|
|
78
|
+
supporting fields feed Layer-2 fuzzy scoring. State signals are intentionally
|
|
79
|
+
NOT read — state is never identity.
|
|
80
|
+
"""
|
|
81
|
+
bbox = _safe_bbox(signals.get("boundingBox"))
|
|
82
|
+
ordinal = signals.get("ordinal")
|
|
83
|
+
return ElementFingerprint(
|
|
84
|
+
# ── canonical identity (Node-emitted, verbatim) ──
|
|
85
|
+
tag_canon=_s(signals.get("tag_canon")),
|
|
86
|
+
role_canon=_s(signals.get("role_canon")),
|
|
87
|
+
accessible_name_canon=_s(signals.get("accessibleName_canon")),
|
|
88
|
+
testid_canon=_s(signals.get("testid_canon")),
|
|
89
|
+
name_attr_canon=_s(signals.get("nameAttr_canon")),
|
|
90
|
+
id_canon=_s(signals.get("id_canon")),
|
|
91
|
+
# ── raw supporting (Layer-2) ──
|
|
92
|
+
visible_text=_s(signals.get("text")),
|
|
93
|
+
neighbor_text=_s(signals.get("neighborText")),
|
|
94
|
+
class_tokens=_class_tokens(signals.get("classTokens")),
|
|
95
|
+
href=_s(signals.get("href")),
|
|
96
|
+
alt=_s(signals.get("alt")),
|
|
97
|
+
title=_s(signals.get("title")),
|
|
98
|
+
type_attr=_s(signals.get("type")).lower(),
|
|
99
|
+
placeholder=_s(signals.get("placeholder")),
|
|
100
|
+
aria_description=_s(signals.get("ariaDescription")),
|
|
101
|
+
id_xpath=_s(signals.get("idXpath")),
|
|
102
|
+
# ── geometry (validated 4-tuple or None) ──
|
|
103
|
+
bbox=bbox,
|
|
104
|
+
# ── dynamic-id trust (mirrors backend heuristic on the RAW id) ──
|
|
105
|
+
id_is_dynamic=id_is_dynamic(signals.get("id")),
|
|
106
|
+
# ── repeated-element container scoping ──
|
|
107
|
+
container_key=_s(signals.get("containerKey")),
|
|
108
|
+
ordinal=ordinal if isinstance(ordinal, int) else None,
|
|
109
|
+
distinguishing_text=_s(signals.get("distinguishingText")),
|
|
110
|
+
)
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Element fingerprint + deterministic matcher (Phase 1, foundation).
|
|
2
|
+
|
|
3
|
+
The recognition layer's per-element identity. Two layers:
|
|
4
|
+
|
|
5
|
+
- **Layer 1 — exact composite hash** (``fingerprint_hash``): sha256 over the
|
|
6
|
+
Node-emitted ``*_canon`` identity fields. O(1) relocation when nothing
|
|
7
|
+
churned. Hash is an OPTIMISATION, not the matcher — on real apps testid is
|
|
8
|
+
often absent and accessibleName churns with i18n, so the fast path misses
|
|
9
|
+
and Layer 2 carries the load.
|
|
10
|
+
- **Layer 2 — weighted-overlap fuzzy score** (on hash miss): Similo two-tier
|
|
11
|
+
weights over the raw signals, consuming the shared comparators in
|
|
12
|
+
:mod:`ordel.core.similarity`. Survives dynamic ids / xpath churn.
|
|
13
|
+
|
|
14
|
+
Canonicalization is NODE's job: Python NEVER recomputes the accessible name.
|
|
15
|
+
It hashes the Node-emitted ``*_canon`` fields verbatim (kills Node↔Python
|
|
16
|
+
drift). See ``locator-inference.ts readSignals``.
|
|
17
|
+
|
|
18
|
+
State signals (expanded/checked/selected/disabled/required) are captured but
|
|
19
|
+
NOT part of identity — a toggled checkbox still matches.
|
|
20
|
+
|
|
21
|
+
``match(candidate, existing)`` → :class:`MatchResult` with a verdict of
|
|
22
|
+
SAME / AMBIGUOUS / NEW. AMBIGUOUS is the band Phase-4 routes to the thin
|
|
23
|
+
deferred LLM tiebreaker (reusing the heal propose→approve path); Phase 1 only
|
|
24
|
+
produces the deterministic verdict + the narrowed top candidates.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import unicodedata
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from enum import StrEnum
|
|
33
|
+
|
|
34
|
+
from ordel_engine.similarity import (
|
|
35
|
+
SignalScore,
|
|
36
|
+
normalized_euclidean,
|
|
37
|
+
normalized_levenshtein,
|
|
38
|
+
token_jaccard,
|
|
39
|
+
weighted_overlap,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ── Thresholds (shared config; Phase-5 tunes these together with page bands) ──
|
|
43
|
+
# Kept here as module constants; a later phase moves them into the
|
|
44
|
+
# per-app-overridable shared threshold config alongside the page
|
|
45
|
+
# AUTO_MERGE/CANDIDATE bands. Do NOT hardcode at call sites.
|
|
46
|
+
# Phase-5 TUNED via scripts/recognition_bench.py against the VON-Similo Google
|
|
47
|
+
# corpus (84 real DOM-evolution pairs): the 0.70/0.55/0.05 operating point gives
|
|
48
|
+
# 97.5% accuracy / 100% precision / 90.5% recall with ZERO false merges (vs
|
|
49
|
+
# 95.0% / 81% recall at the original 0.85/0.65/0.10) — recovers missed
|
|
50
|
+
# relocations with no added false merges. Re-run the bench if the signal set
|
|
51
|
+
# changes. accept > fuzzy is required by the match() banding logic.
|
|
52
|
+
ACCEPT_THRESHOLD = 0.70
|
|
53
|
+
FUZZY_THRESHOLD = 0.55
|
|
54
|
+
MARGIN = 0.05
|
|
55
|
+
|
|
56
|
+
# Similo tier weights (plan §TIER WEIGHTS).
|
|
57
|
+
_W_TIER1 = 1.5
|
|
58
|
+
_W_TIER2 = 0.5
|
|
59
|
+
|
|
60
|
+
# Geometry tolerance scales (px) for the normalized-Euclidean comparators.
|
|
61
|
+
# Generous by design — geometry is a weak supporting signal, not identity.
|
|
62
|
+
_LOCATION_SCALE = 600.0
|
|
63
|
+
_AREA_SCALE = 200.0
|
|
64
|
+
_SHAPE_SCALE = 4.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Verdict(StrEnum):
|
|
68
|
+
"""The deterministic matcher's decision."""
|
|
69
|
+
|
|
70
|
+
SAME = "same"
|
|
71
|
+
AMBIGUOUS = "ambiguous" # → Phase-4 LLM tiebreaker / human gate
|
|
72
|
+
NEW = "new"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canon_text(value: str | None) -> str:
|
|
76
|
+
"""Canonicalise a raw text field the SAME way Node does for *_canon.
|
|
77
|
+
|
|
78
|
+
Lowercase + Unicode-NFC + whitespace-collapsed. Used ONLY for Layer-2
|
|
79
|
+
fuzzy comparison of RAW fields Python receives without a canonical
|
|
80
|
+
twin — NEVER to recompute an identity field Node already canonicalised.
|
|
81
|
+
The identity hash hashes the Node ``*_canon`` fields verbatim via
|
|
82
|
+
:func:`compute_fingerprint_hash`.
|
|
83
|
+
"""
|
|
84
|
+
if not value:
|
|
85
|
+
return ""
|
|
86
|
+
out = unicodedata.normalize("NFC", value)
|
|
87
|
+
out = " ".join(out.split())
|
|
88
|
+
return out.lower()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ElementFingerprint:
|
|
93
|
+
"""One element's full captured signal record.
|
|
94
|
+
|
|
95
|
+
Identity fields are the Node-emitted ``*_canon`` values (already
|
|
96
|
+
lowercased + NFC + whitespace-collapsed). Raw fields support Layer-2
|
|
97
|
+
fuzzy comparison. State + geometry are captured but state is never
|
|
98
|
+
identity.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# Canonical identity fields (Node-emitted; hashed VERBATIM).
|
|
102
|
+
tag_canon: str = ""
|
|
103
|
+
role_canon: str = ""
|
|
104
|
+
accessible_name_canon: str = ""
|
|
105
|
+
testid_canon: str = ""
|
|
106
|
+
name_attr_canon: str = ""
|
|
107
|
+
id_canon: str = ""
|
|
108
|
+
|
|
109
|
+
# Raw supporting signals (Layer-2 fuzzy).
|
|
110
|
+
visible_text: str = ""
|
|
111
|
+
neighbor_text: str = ""
|
|
112
|
+
class_tokens: frozenset[str] = field(default_factory=frozenset)
|
|
113
|
+
href: str = ""
|
|
114
|
+
alt: str = ""
|
|
115
|
+
title: str = ""
|
|
116
|
+
type_attr: str = ""
|
|
117
|
+
placeholder: str = ""
|
|
118
|
+
aria_description: str = ""
|
|
119
|
+
xpath: str = ""
|
|
120
|
+
id_xpath: str = ""
|
|
121
|
+
|
|
122
|
+
# Geometry (location/area/shape) from the bounding box.
|
|
123
|
+
bbox: tuple[float, float, float, float] | None = None # x, y, w, h
|
|
124
|
+
|
|
125
|
+
# ``id`` is trusted in the identity hash only when NOT dynamic.
|
|
126
|
+
id_is_dynamic: bool = False
|
|
127
|
+
|
|
128
|
+
# Container scoping for repeated-element disambiguation (table rows etc.).
|
|
129
|
+
container_key: str = ""
|
|
130
|
+
ordinal: int | None = None
|
|
131
|
+
distinguishing_text: str = ""
|
|
132
|
+
|
|
133
|
+
def location(self) -> tuple[float, float]:
|
|
134
|
+
if self.bbox is None:
|
|
135
|
+
return (0.0, 0.0)
|
|
136
|
+
return (self.bbox[0], self.bbox[1])
|
|
137
|
+
|
|
138
|
+
def area(self) -> tuple[float]:
|
|
139
|
+
if self.bbox is None:
|
|
140
|
+
return (0.0,)
|
|
141
|
+
return (self.bbox[2] * self.bbox[3],)
|
|
142
|
+
|
|
143
|
+
def shape(self) -> tuple[float]:
|
|
144
|
+
"""Aspect ratio (w/h) — invariant to scaling, captures shape."""
|
|
145
|
+
if self.bbox is None or self.bbox[3] == 0:
|
|
146
|
+
return (0.0,)
|
|
147
|
+
return (self.bbox[2] / self.bbox[3],)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def compute_fingerprint_hash(fp: ElementFingerprint) -> str:
|
|
151
|
+
"""Layer-1 composite exact hash over the Node-emitted ``*_canon`` fields.
|
|
152
|
+
|
|
153
|
+
sha256 over (tag, id*, name_attr, testid, role, accessible_name). ``id``
|
|
154
|
+
is included ONLY when it passes the dynamic filter. EXCLUDES
|
|
155
|
+
bbox/xpath/class/href/state — those are Layer-2 / never-identity.
|
|
156
|
+
|
|
157
|
+
Container-scoped repeated elements (table rows / grid cells) fold their
|
|
158
|
+
ordinal + distinguishing leaf text into the hash so row-2's button !=
|
|
159
|
+
row-5's button deterministically (must_fix #7).
|
|
160
|
+
"""
|
|
161
|
+
id_part = "" if fp.id_is_dynamic else fp.id_canon
|
|
162
|
+
parts = [
|
|
163
|
+
fp.tag_canon,
|
|
164
|
+
id_part,
|
|
165
|
+
fp.name_attr_canon,
|
|
166
|
+
fp.testid_canon,
|
|
167
|
+
fp.role_canon,
|
|
168
|
+
fp.accessible_name_canon,
|
|
169
|
+
]
|
|
170
|
+
# Container disambiguation: only folds in when the element is part of a
|
|
171
|
+
# detected repeated-sibling group (ordinal set). A standalone element
|
|
172
|
+
# gets ordinal=None and hashes identically to its old pre-container form.
|
|
173
|
+
if fp.ordinal is not None:
|
|
174
|
+
parts.append(f"@{fp.container_key}#{fp.ordinal}")
|
|
175
|
+
parts.append(canon_text(fp.distinguishing_text))
|
|
176
|
+
joined = "\x1f".join(parts)
|
|
177
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _id_signal(fp: ElementFingerprint) -> tuple[str, bool]:
|
|
181
|
+
"""``id`` is a Tier-1 identity signal only when NOT dynamic."""
|
|
182
|
+
if fp.id_is_dynamic or not fp.id_canon:
|
|
183
|
+
return ("", False)
|
|
184
|
+
return (fp.id_canon, True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _testid_fast_path(
|
|
188
|
+
candidate: ElementFingerprint, existing: list[tuple[str, ElementFingerprint]]
|
|
189
|
+
) -> str | None:
|
|
190
|
+
"""Layer 1.5 — exact ``testid`` match, tag + ordinal guarded.
|
|
191
|
+
|
|
192
|
+
``testid`` (a ``data-testid``/QA hook) is author-supplied specifically to
|
|
193
|
+
give tooling a text-independent identity anchor — it is meant to be the
|
|
194
|
+
single strongest signal the matcher can receive. But the Layer-1 exact
|
|
195
|
+
hash (:func:`compute_fingerprint_hash`) ALSO requires
|
|
196
|
+
``accessible_name_canon`` equality, which never holds across a translated
|
|
197
|
+
re-capture (locale/i18n validation, GAP-2109). That forces every
|
|
198
|
+
translated element through Layer-2 fuzzy scoring, where testid is
|
|
199
|
+
diluted to one of ~11 equally-weighted Tier-1 signals — several of which
|
|
200
|
+
(accessible_name, visible_text, neighbor_text, and often geometry from
|
|
201
|
+
text-driven layout reflow) degrade simultaneously under translation. A
|
|
202
|
+
testid-identical element can then score below ``FUZZY_THRESHOLD`` and
|
|
203
|
+
resolve NEW: a silent duplicate of an element the app unambiguously
|
|
204
|
+
already has.
|
|
205
|
+
|
|
206
|
+
This fast path restores testid's intended role as a near-decisive
|
|
207
|
+
identity anchor when present (non-empty) and equal on both sides.
|
|
208
|
+
Tag-guarded to catch accidental testid collisions across unrelated
|
|
209
|
+
element types; ordinal/container-guarded to preserve per-row
|
|
210
|
+
disambiguation in repeated groups (must_fix #7) when either side is
|
|
211
|
+
part of a detected repeated-sibling group.
|
|
212
|
+
"""
|
|
213
|
+
if not candidate.testid_canon:
|
|
214
|
+
return None
|
|
215
|
+
for elem_id, fp in existing:
|
|
216
|
+
if fp.testid_canon != candidate.testid_canon:
|
|
217
|
+
continue
|
|
218
|
+
if fp.tag_canon != candidate.tag_canon:
|
|
219
|
+
continue
|
|
220
|
+
if candidate.ordinal is not None or fp.ordinal is not None:
|
|
221
|
+
if candidate.ordinal != fp.ordinal or candidate.container_key != fp.container_key:
|
|
222
|
+
continue
|
|
223
|
+
return elem_id
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def score(a: ElementFingerprint, b: ElementFingerprint) -> float:
|
|
228
|
+
"""Layer-2 weighted-overlap fuzzy similarity in ``[0, 1]``.
|
|
229
|
+
|
|
230
|
+
Similo two-tier weights over the signal set, with proportional
|
|
231
|
+
redistribution of absent signals (shared with the page scorer). State
|
|
232
|
+
signals are excluded from identity scoring by construction.
|
|
233
|
+
"""
|
|
234
|
+
a_id, a_id_present = _id_signal(a)
|
|
235
|
+
b_id, b_id_present = _id_signal(b)
|
|
236
|
+
id_present = a_id_present and b_id_present
|
|
237
|
+
|
|
238
|
+
geom = a.bbox is not None and b.bbox is not None
|
|
239
|
+
loc = (normalized_euclidean(a.location(), b.location(), scale=_LOCATION_SCALE), geom)
|
|
240
|
+
area = (normalized_euclidean(a.area(), b.area(), scale=_AREA_SCALE), geom)
|
|
241
|
+
shape = (normalized_euclidean(a.shape(), b.shape(), scale=_SHAPE_SCALE), geom)
|
|
242
|
+
acc_name = _lev_present(a.accessible_name_canon, b.accessible_name_canon)
|
|
243
|
+
vis_text = _lev_present(canon_text(a.visible_text), canon_text(b.visible_text))
|
|
244
|
+
nbr_text = _jaccard_present(_tokens(a.neighbor_text), _tokens(b.neighbor_text))
|
|
245
|
+
placeholder = _lev_present(canon_text(a.placeholder), canon_text(b.placeholder))
|
|
246
|
+
aria_desc = _lev_present(canon_text(a.aria_description), canon_text(b.aria_description))
|
|
247
|
+
|
|
248
|
+
signals: list[SignalScore] = [
|
|
249
|
+
# ── Tier 1 (1.5) — the identity spine ──
|
|
250
|
+
_sig("testid", _W_TIER1, _eq_canon(a.testid_canon, b.testid_canon)),
|
|
251
|
+
_sig("id", _W_TIER1, (1.0 if a_id == b_id else 0.0, id_present)),
|
|
252
|
+
_sig("name_attr", _W_TIER1, _eq_canon(a.name_attr_canon, b.name_attr_canon)),
|
|
253
|
+
_sig("tag", _W_TIER1, _eq_canon(a.tag_canon, b.tag_canon)),
|
|
254
|
+
_sig("role", _W_TIER1, _eq_canon(a.role_canon, b.role_canon)),
|
|
255
|
+
_sig("accessible_name", _W_TIER1, acc_name),
|
|
256
|
+
_sig("visible_text", _W_TIER1, vis_text),
|
|
257
|
+
_sig("neighbor_text", _W_TIER1, nbr_text),
|
|
258
|
+
_sig("location", _W_TIER1, loc),
|
|
259
|
+
_sig("area", _W_TIER1, area),
|
|
260
|
+
_sig("shape", _W_TIER1, shape),
|
|
261
|
+
# ── Tier 2 (0.5) — supporting ──
|
|
262
|
+
_sig("class", _W_TIER2, _jaccard_present_set(a.class_tokens, b.class_tokens)),
|
|
263
|
+
_sig("href", _W_TIER2, _lev_present(a.href, b.href)),
|
|
264
|
+
_sig("alt", _W_TIER2, _lev_present(a.alt, b.alt)),
|
|
265
|
+
_sig("title", _W_TIER2, _lev_present(a.title, b.title)),
|
|
266
|
+
_sig("type", _W_TIER2, _eq_canon(a.type_attr, b.type_attr)),
|
|
267
|
+
_sig("placeholder", _W_TIER2, placeholder),
|
|
268
|
+
_sig("aria_description", _W_TIER2, aria_desc),
|
|
269
|
+
_sig("id_xpath", _W_TIER2, _lev_present(a.id_xpath, b.id_xpath)),
|
|
270
|
+
]
|
|
271
|
+
return weighted_overlap(signals)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _sig(name: str, weight: float, sim_present: tuple[float, bool]) -> SignalScore:
|
|
275
|
+
sim, present = sim_present
|
|
276
|
+
return SignalScore(name=name, weight=weight, similarity=sim, present=present)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _eq_canon(a: str, b: str) -> tuple[float, bool]:
|
|
280
|
+
"""Equality over a canonical field; absent when either side is empty."""
|
|
281
|
+
if not a or not b:
|
|
282
|
+
return (0.0, False)
|
|
283
|
+
return (1.0 if a == b else 0.0, True)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _lev_present(a: str, b: str) -> tuple[float, bool]:
|
|
287
|
+
if not a and not b:
|
|
288
|
+
return (0.0, False)
|
|
289
|
+
return (normalized_levenshtein(a, b), True)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _jaccard_present(a: set[str], b: set[str]) -> tuple[float, bool]:
|
|
293
|
+
if not a and not b:
|
|
294
|
+
return (0.0, False)
|
|
295
|
+
return (token_jaccard(a, b), True)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _jaccard_present_set(a: frozenset[str], b: frozenset[str]) -> tuple[float, bool]:
|
|
299
|
+
if not a and not b:
|
|
300
|
+
return (0.0, False)
|
|
301
|
+
return (token_jaccard(a, b), True)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _tokens(text: str) -> set[str]:
|
|
305
|
+
return {t for t in canon_text(text).split() if t}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(frozen=True)
|
|
309
|
+
class MatchResult:
|
|
310
|
+
"""Outcome of :func:`match`.
|
|
311
|
+
|
|
312
|
+
- ``verdict`` SAME → ``matched_id`` is the existing element's id.
|
|
313
|
+
- ``verdict`` AMBIGUOUS → ``top`` holds the narrowed (id, score) pairs
|
|
314
|
+
Phase-4 hands to the thin LLM tiebreaker / human gate.
|
|
315
|
+
- ``verdict`` NEW → no existing element cleared the bar.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
verdict: Verdict
|
|
319
|
+
matched_id: str | None
|
|
320
|
+
best_score: float
|
|
321
|
+
margin: float
|
|
322
|
+
top: list[tuple[str, float]]
|
|
323
|
+
exact_hash_hit: bool
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def match(
|
|
327
|
+
candidate: ElementFingerprint,
|
|
328
|
+
existing: list[tuple[str, ElementFingerprint]],
|
|
329
|
+
*,
|
|
330
|
+
accept: float = ACCEPT_THRESHOLD,
|
|
331
|
+
fuzzy: float = FUZZY_THRESHOLD,
|
|
332
|
+
margin: float = MARGIN,
|
|
333
|
+
) -> MatchResult:
|
|
334
|
+
"""Deterministic element match → SAME / AMBIGUOUS / NEW.
|
|
335
|
+
|
|
336
|
+
Decision gates (plan §DECISION GATES):
|
|
337
|
+
1. exact ``fingerprint_hash`` hit → SAME (O(1) relocation).
|
|
338
|
+
1.5. else exact ``testid`` match (tag + ordinal guarded) → SAME. Covers
|
|
339
|
+
the i18n case where ``accessible_name`` churns (breaking gate 1) but
|
|
340
|
+
the author-supplied testid — the strongest identity anchor when
|
|
341
|
+
present — did not (see :func:`_testid_fast_path`).
|
|
342
|
+
2. else argmax over Layer-2 score:
|
|
343
|
+
- best >= accept AND (best - 2nd) >= margin → SAME (auto).
|
|
344
|
+
- best in [fuzzy, accept) OR margin too thin → AMBIGUOUS
|
|
345
|
+
(top-5 → Phase-4 LLM tiebreaker / human gate).
|
|
346
|
+
- best < fuzzy → NEW.
|
|
347
|
+
|
|
348
|
+
Container scoping (repeated-element disambiguation): when the candidate
|
|
349
|
+
is part of a detected repeated-sibling group, existing rows in the SAME
|
|
350
|
+
container are preferred — the hash already folds ordinal+leaf-text so the
|
|
351
|
+
fast path resolves each row distinctly with ZERO LLM tiebreaker calls.
|
|
352
|
+
"""
|
|
353
|
+
if not existing:
|
|
354
|
+
return MatchResult(Verdict.NEW, None, 0.0, 0.0, [], exact_hash_hit=False)
|
|
355
|
+
|
|
356
|
+
cand_hash = compute_fingerprint_hash(candidate)
|
|
357
|
+
for elem_id, fp in existing:
|
|
358
|
+
if compute_fingerprint_hash(fp) == cand_hash:
|
|
359
|
+
return MatchResult(
|
|
360
|
+
Verdict.SAME, elem_id, 1.0, 1.0, [(elem_id, 1.0)], exact_hash_hit=True
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
testid_hit = _testid_fast_path(candidate, existing)
|
|
364
|
+
if testid_hit is not None:
|
|
365
|
+
return MatchResult(
|
|
366
|
+
Verdict.SAME, testid_hit, 1.0, 1.0, [(testid_hit, 1.0)], exact_hash_hit=True
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
scored = sorted(
|
|
370
|
+
((elem_id, score(candidate, fp)) for elem_id, fp in existing),
|
|
371
|
+
key=lambda t: t[1],
|
|
372
|
+
reverse=True,
|
|
373
|
+
)
|
|
374
|
+
best_id, best = scored[0]
|
|
375
|
+
second = scored[1][1] if len(scored) > 1 else 0.0
|
|
376
|
+
gap = best - second
|
|
377
|
+
top5 = scored[:5]
|
|
378
|
+
|
|
379
|
+
if best >= accept and gap >= margin:
|
|
380
|
+
return MatchResult(Verdict.SAME, best_id, best, gap, top5, exact_hash_hit=False)
|
|
381
|
+
# Either the FUZZY band [fuzzy, accept), OR a high best with too thin a
|
|
382
|
+
# margin (two near-ties): both route to the deferred LLM tiebreaker / human
|
|
383
|
+
# gate. Since accept > fuzzy (by construction), one ``best >= fuzzy`` check
|
|
384
|
+
# covers the thin-margin high-score case too.
|
|
385
|
+
if best >= fuzzy:
|
|
386
|
+
return MatchResult(Verdict.AMBIGUOUS, None, best, gap, top5, exact_hash_hit=False)
|
|
387
|
+
return MatchResult(Verdict.NEW, None, best, gap, top5, exact_hash_hit=False)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Deterministic self-heal — the free-tier runtime healer.
|
|
2
|
+
|
|
3
|
+
Assembles the existing pure matcher (:func:`ordel_engine.element_fingerprint.match`)
|
|
4
|
+
into a runtime heal call. NO LLM on any path — the whole free-tier "zero Ordel LLM"
|
|
5
|
+
guarantee lives here by construction:
|
|
6
|
+
|
|
7
|
+
- confident match → :class:`HealOutcome` (the healed element id).
|
|
8
|
+
- ambiguous tail → raise :class:`AmbiguityError` carrying the ranked candidates.
|
|
9
|
+
The MCP server catches this and hands the tiebreak to the BYO coding agent — the
|
|
10
|
+
agent's own LLM, never Ordel's.
|
|
11
|
+
- element gone → raise :class:`HealFailedError` → an ACTIONABLE red, never a
|
|
12
|
+
silent skip (a testing tool must not hide breakage).
|
|
13
|
+
|
|
14
|
+
This is a strict improvement over the hosted heal path, which resolves ambiguity by
|
|
15
|
+
silently picking the top candidate; here the agent (which is right there) decides.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from ordel_engine.element_fingerprint import ElementFingerprint, MatchResult, Verdict, match
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HealError(Exception):
|
|
26
|
+
"""Base — the deterministic engine could not confidently heal on its own."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AmbiguityError(HealError):
|
|
30
|
+
"""Thin-margin / FUZZY band: several candidates, none a clear winner.
|
|
31
|
+
|
|
32
|
+
Carries the ranked ``candidates`` (``[(element_id, score), ...]``) so the caller
|
|
33
|
+
(the MCP server) can hand them to the BYO agent to adjudicate. On the free path
|
|
34
|
+
this is what the engine RAISES instead of ever calling an LLM itself.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, candidates: list[tuple[str, float]], best: float) -> None:
|
|
38
|
+
self.candidates = candidates
|
|
39
|
+
self.best = best
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"ambiguous heal: {len(candidates)} candidates, best={best:.3f} below "
|
|
42
|
+
f"the confident margin — defer to the BYO agent"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HealFailedError(HealError):
|
|
47
|
+
"""No candidate cleared the fuzzy bar — the element is gone from this snapshot.
|
|
48
|
+
|
|
49
|
+
The caller surfaces this as an ACTIONABLE test failure (broken selector + the
|
|
50
|
+
best-effort candidates), which the coding agent repairs in a later editor
|
|
51
|
+
session. Never silently skipped.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, best: float) -> None:
|
|
55
|
+
self.best = best
|
|
56
|
+
super().__init__(f"no heal: best score {best:.3f} below the fuzzy threshold")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class HealOutcome:
|
|
61
|
+
"""A confident heal. ``exact`` is True when Layer-1 (hash / testid) resolved it,
|
|
62
|
+
False when the Layer-2 fuzzy scorer carried it (survived real DOM churn)."""
|
|
63
|
+
|
|
64
|
+
healed_id: str
|
|
65
|
+
score: float
|
|
66
|
+
exact: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def heal_by_fingerprint(
|
|
70
|
+
broken: ElementFingerprint,
|
|
71
|
+
candidates: list[tuple[str, ElementFingerprint]],
|
|
72
|
+
) -> HealOutcome:
|
|
73
|
+
"""Heal a broken element against the current page's elements.
|
|
74
|
+
|
|
75
|
+
Pure delegation to the deterministic matcher — no DB, no LLM. Raises
|
|
76
|
+
:class:`AmbiguityError` / :class:`HealFailedError` for the tail the matcher
|
|
77
|
+
cannot resolve alone.
|
|
78
|
+
"""
|
|
79
|
+
result: MatchResult = match(broken, candidates)
|
|
80
|
+
if result.verdict is Verdict.SAME and result.matched_id is not None:
|
|
81
|
+
return HealOutcome(result.matched_id, result.best_score, result.exact_hash_hit)
|
|
82
|
+
if result.verdict is Verdict.AMBIGUOUS:
|
|
83
|
+
raise AmbiguityError(result.top, result.best_score)
|
|
84
|
+
raise HealFailedError(result.best_score)
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Shared similarity primitives for the recognition layer.
|
|
2
|
+
|
|
3
|
+
ONE engine consumed by BOTH the element matcher
|
|
4
|
+
(:mod:`ordel.core.element_fingerprint`) AND the shipped page-identity
|
|
5
|
+
scorer (:mod:`ordel.core.services.page_identity_service`). Extracting
|
|
6
|
+
these primitives into a single module is gap #4 of the recognition-layer
|
|
7
|
+
plan: the two weighted-overlap engines users perceive as one feature must
|
|
8
|
+
not drift.
|
|
9
|
+
|
|
10
|
+
Pure functions, no I/O. Every comparator returns a similarity in ``[0, 1]``
|
|
11
|
+
(1.0 = identical). The weighted aggregator implements the
|
|
12
|
+
"redistribute-proportionally-on-missing-signal" rule so an artifact with an
|
|
13
|
+
absent signal is not penalised for the gap.
|
|
14
|
+
|
|
15
|
+
Reference: Healenium treecomparing (weighted-LCS + heuristic node distance),
|
|
16
|
+
Similo tiered weighting. Ported to Python; not a dependency.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
# ── String comparators ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalized_levenshtein(a: str, b: str) -> float:
|
|
28
|
+
"""Edit-distance similarity in ``[0, 1]``: ``1 - dist / max(len)``.
|
|
29
|
+
|
|
30
|
+
Two empty strings are identical (1.0). Case-sensitive — callers that
|
|
31
|
+
want case-insensitivity should pass already-lowered strings (the
|
|
32
|
+
identity *_canon fields are pre-lowered Node-side).
|
|
33
|
+
"""
|
|
34
|
+
if a == b:
|
|
35
|
+
return 1.0
|
|
36
|
+
if not a or not b:
|
|
37
|
+
return 0.0
|
|
38
|
+
dist = _levenshtein_distance(a, b)
|
|
39
|
+
longest = max(len(a), len(b))
|
|
40
|
+
if longest == 0:
|
|
41
|
+
return 1.0
|
|
42
|
+
return 1.0 - dist / longest
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _levenshtein_distance(a: str, b: str) -> int:
|
|
46
|
+
"""Classic two-row dynamic-programming Levenshtein distance."""
|
|
47
|
+
if a == b:
|
|
48
|
+
return 0
|
|
49
|
+
if not a:
|
|
50
|
+
return len(b)
|
|
51
|
+
if not b:
|
|
52
|
+
return len(a)
|
|
53
|
+
# Ensure b is the shorter for a narrower row.
|
|
54
|
+
if len(a) < len(b):
|
|
55
|
+
a, b = b, a
|
|
56
|
+
previous = list(range(len(b) + 1))
|
|
57
|
+
for i, ca in enumerate(a, start=1):
|
|
58
|
+
current = [i]
|
|
59
|
+
for j, cb in enumerate(b, start=1):
|
|
60
|
+
insert = current[j - 1] + 1
|
|
61
|
+
delete = previous[j] + 1
|
|
62
|
+
substitute = previous[j - 1] + (0 if ca == cb else 1)
|
|
63
|
+
current.append(min(insert, delete, substitute))
|
|
64
|
+
previous = current
|
|
65
|
+
return previous[-1]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def token_jaccard(a: Iterable[str], b: Iterable[str]) -> float:
|
|
69
|
+
"""Jaccard overlap of two token sets in ``[0, 1]``.
|
|
70
|
+
|
|
71
|
+
Two empty sets are identical (1.0) — an element with no classes
|
|
72
|
+
matches another with no classes on this signal rather than being
|
|
73
|
+
forced to 0.
|
|
74
|
+
"""
|
|
75
|
+
set_a = set(a)
|
|
76
|
+
set_b = set(b)
|
|
77
|
+
if not set_a and not set_b:
|
|
78
|
+
return 1.0
|
|
79
|
+
union = set_a | set_b
|
|
80
|
+
if not union:
|
|
81
|
+
return 1.0
|
|
82
|
+
return len(set_a & set_b) / len(union)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def exact_ci(a: str | None, b: str | None) -> float:
|
|
86
|
+
"""Case-insensitive exact-equality comparator → 1.0 / 0.0.
|
|
87
|
+
|
|
88
|
+
Empty/None on either side → 0.0 (absent, not a match). Callers that
|
|
89
|
+
want absent-on-both to be treated as "present and equal" should mark
|
|
90
|
+
the signal absent via the weighted aggregator instead.
|
|
91
|
+
"""
|
|
92
|
+
if not a or not b:
|
|
93
|
+
return 0.0
|
|
94
|
+
return 1.0 if a.strip().lower() == b.strip().lower() else 0.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Numeric comparators ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def normalized_euclidean(
|
|
101
|
+
a: tuple[float, ...], b: tuple[float, ...], *, scale: float
|
|
102
|
+
) -> float:
|
|
103
|
+
"""Geometry similarity in ``[0, 1]`` from Euclidean distance.
|
|
104
|
+
|
|
105
|
+
``scale`` is the distance at which similarity reaches 0 (clamped). A
|
|
106
|
+
distance of 0 → 1.0; distance >= ``scale`` → 0.0. Used for bounding-box
|
|
107
|
+
location/area/shape signals where a tunable tolerance is wanted.
|
|
108
|
+
"""
|
|
109
|
+
if len(a) != len(b):
|
|
110
|
+
raise ValueError(f"dimension mismatch: {len(a)} vs {len(b)}")
|
|
111
|
+
if scale <= 0:
|
|
112
|
+
return 1.0 if a == b else 0.0
|
|
113
|
+
dist_sq: float = sum((x - y) ** 2 for x, y in zip(a, b, strict=True))
|
|
114
|
+
dist: float = dist_sq**0.5
|
|
115
|
+
if dist >= scale:
|
|
116
|
+
return 0.0
|
|
117
|
+
return 1.0 - dist / scale
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Weighted aggregation with proportional redistribution ─────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class SignalScore:
|
|
125
|
+
"""One weighted signal's contribution to an overlap score.
|
|
126
|
+
|
|
127
|
+
``present`` distinguishes "the signal is absent on one/both sides"
|
|
128
|
+
(weight redistributed) from "the signal is present and scored 0.0"
|
|
129
|
+
(weight applied, drags the score down).
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
name: str
|
|
133
|
+
weight: float
|
|
134
|
+
similarity: float
|
|
135
|
+
present: bool
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def weighted_overlap(signals: list[SignalScore]) -> float:
|
|
139
|
+
"""Aggregate weighted signals into a ``[0, 1]`` overlap score.
|
|
140
|
+
|
|
141
|
+
Implements the "redistribute proportionally" rule: the weights of
|
|
142
|
+
*present* signals are rescaled to sum to 1.0, so an absent signal
|
|
143
|
+
neither contributes nor dilutes. When no signal is present the score
|
|
144
|
+
is 0.0.
|
|
145
|
+
|
|
146
|
+
This is the exact algorithm the shipped page-identity scorer uses;
|
|
147
|
+
extracting it here keeps the element matcher and the page scorer on
|
|
148
|
+
one implementation.
|
|
149
|
+
"""
|
|
150
|
+
present_weight_total = sum(s.weight for s in signals if s.present)
|
|
151
|
+
if present_weight_total == 0.0:
|
|
152
|
+
return 0.0
|
|
153
|
+
final = 0.0
|
|
154
|
+
for s in signals:
|
|
155
|
+
if not s.present:
|
|
156
|
+
continue
|
|
157
|
+
scaled = s.weight / present_weight_total
|
|
158
|
+
final += scaled * s.similarity
|
|
159
|
+
return float(final)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ordel-engine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ordel's deterministic QA engine — the pure functional core (fingerprint matching + self-heal) that runs locally in the free CLI with no DB, no backend, no LLM."
|
|
9
|
+
# PyPI-facing readme only (the repo README.md documents SaaS-internal shims and is NOT shipped).
|
|
10
|
+
readme = "README.pypi.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = "Apache-2.0"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
authors = [{ name = "Ordel" }]
|
|
15
|
+
keywords = ["testing", "qa", "fingerprint", "self-healing", "selectors"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [] # pure stdlib — the whole point: runs standalone on a dev laptop
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://ordel.io"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["ordel_engine"]
|
|
32
|
+
|
|
33
|
+
# Lean sdist: ship ONLY the package + license + the PyPI-facing readme (NOT the repo
|
|
34
|
+
# README.md, which documents SaaS-internal shims). No tests/caches.
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = [
|
|
37
|
+
"ordel_engine",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"README.pypi.md",
|
|
40
|
+
"pyproject.toml",
|
|
41
|
+
]
|