Skip to content

Commit c5edfea

Browse files
authored
Merge branch 'main' into feature/structured-extraction-b3
2 parents a5cd582 + 4c354bc commit c5edfea

14 files changed

Lines changed: 1017 additions & 48 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ standards_cache.sqlite
6464

6565
### Docs
6666
*.md
67+
!AGENTS.md
6768

6869
### Dev DBDumps
6970
*.sql

AGENTS.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# OpenCRE Agent Instructions
2+
3+
Cursor agents working in this repo must follow the rules in `.cursor/rules/`.
4+
5+
## Quick start
6+
7+
1. **Requirements gate** — If goal, success criteria, `@` file refs, or constraints are missing, stop and ask (`requirements-gate.mdc`).
8+
2. **Plan first** — Non-trivial or multi-file work requires a plan and user approval before coding (`plan-first-workflow.mdc`, `multi-agent-workflow.mdc`).
9+
3. **Verify** — After code changes, run checks and iterate until green (`verifiable-goals.mdc`):
10+
- `make lint`
11+
- `make mypy`
12+
- `make test`
13+
- `make frontend` (if frontend touched)
14+
4. **Review** — Substantive work needs independent judge/subagent review (`multi-agent-workflow.mdc`).
15+
5. **Production gap analysis** — On Heroku/opencreorg, gap analysis is cache-only: serve precomputed rows from Postgres; do not compute GA on production (cache miss → 404).
16+
17+
## Operational scripts
18+
19+
Prefer existing Makefile targets and `scripts/` helpers over ad-hoc Docker, GA, import, or prod-DB setup. Read script headers or `--help` for env vars and flags; do not reimplement their logic.
20+
21+
### Local Docker services
22+
23+
Use Makefile targets — do not hand-roll `docker run`:
24+
25+
```bash
26+
make docker-neo4j # Neo4j (7474/7687)
27+
make docker-redis # Redis Stack (6379/8001)
28+
make docker-postgres # local Postgres (5432, cre/password)
29+
make start-containers # neo4j + redis only
30+
make migrate-upgrade # after postgres is up
31+
```
32+
33+
Reset volumes: `make docker-neo4j-rm` / `make docker-redis-rm`.
34+
35+
### Imports and Neo4j populate
36+
37+
```bash
38+
make import-all # full standards import via scripts/import-all.sh
39+
make import-projects # skip core CRE import (CRE_SKIP_IMPORT_CORE=1)
40+
make import-neo4j # populate Neo4j from cache
41+
```
42+
43+
`scripts/import-all.sh` handles parallel importers, SQLite export, and verification. For embeddings-only SQLite → Postgres push, use `scripts/sync_embeddings_table.py` (see its docstring).
44+
45+
### Gap analysis (local compute, prod verify)
46+
47+
**Local backfill** (starts containers, workers, migrations — see `scripts/backfill_gap_analysis.sh`):
48+
49+
```bash
50+
make backfill-gap-analysis # parallel workers + --ga_backfill_missing
51+
make backfill-gap-analysis-sync # Neo4j populate + backfill without queue
52+
make sync-gap-analysis-table-local # upsert material rows sqlite → local Postgres
53+
```
54+
55+
**Prod / staging checks** (HTTP only — never compute GA on opencreorg):
56+
57+
```bash
58+
make verify-ga-complete-prod # scripts/verify_ga_completeness.py
59+
make monitor-ga-health-prod # scripts/monitor_ga_health.py (503 / empty result alerts)
60+
make verify-ga-parity-local # scripts/verify_ga_postgres_neo_parity.py
61+
```
62+
63+
Table sync between databases: `scripts/sync_gap_analysis_table.py`, `scripts/sync_embeddings_table.py`.
64+
65+
### Production DB (Heroku opencreorg)
66+
67+
Use `scripts/db/*` — never raw destructive `psql` against prod without these wrappers. All flows capture and wait for a fresh Heroku backup first (`scripts/db/common.sh`).
68+
69+
```bash
70+
scripts/db/backup-opencreorg.sh # backup only
71+
scripts/db/surgery-opencreorg.sh --sql-file path/to/change.sql # targeted SQL
72+
scripts/db/surgery-opencreorg.sh --sql-file … --destructive # DELETE/DROP/TRUNCATE
73+
scripts/db/sync-local-to-opencreorg.sh [--table node]… # local → prod sync
74+
```
75+
76+
Destructive surgery requires `CONFIRM_DESTRUCTIVE=I_UNDERSTAND_OPENCREORG_PROD_DB_DESTRUCTIVE_ACTION` (exact phrase). Override app: `APP_NAME=opencreorg`. See `production-db-ops-safety.mdc`.
77+
78+
After prod GA cache changes, verify with `make verify-ga-complete-prod` / `make monitor-ga-health-prod`.
79+
80+
### Weekly prod GA & data completeness (Cursor Automation)
81+
82+
Schedule a **Cursor Automation** (not GitHub Actions) to run weekly — prod is cache-only; checks are HTTP + repo scripts only.
83+
84+
| Setting | Value |
85+
|---------|--------|
86+
| Trigger | Cron: `0 9 * * 1` (Mondays 09:00) |
87+
| Repo | `OWASP/OpenCRE`, branch `main` |
88+
| Tools | None required (cloud agent uses repo checkout) |
89+
90+
**Agent prompt (paste into Automations editor):**
91+
92+
```
93+
Weekly OpenCRE prod GA and data completeness for opencreorg.
94+
95+
1. python3 scripts/monitor_ga_health.py --base-url https://opencre.org --output-json tmp/prod-ga-health.json
96+
2. python3 scripts/verify_ga_completeness.py --base-url https://opencre.org --output-json tmp/prod-ga-completeness.json
97+
3. Confirm /rest/v1/standards and /rest/v1/ga_standards return non-empty lists.
98+
4. If incomplete_pairs > 0 or non-zero exit: list failing pairs/buckets; recommend AGENTS.md Operational scripts (local backfill + scripts/sync_gap_analysis_table.py). Do not compute GA on Heroku or run destructive prod DB ops without explicit approval.
99+
5. If all pass: report complete/total pairs and standards counts.
100+
```
101+
102+
Create via **Cursor → Automations → New** (Agents Window). Do not hand-roll docker/GA setup in the automation prompt.
103+
104+
### Staging bootstrap
105+
106+
`scripts/setup-heroku-staging.sh` — provisions staging from prod + local SQLite; supports `--embeddings`, `--gap_analysis`, or full sync. Requires `PROD_APP`, `STAGING_APP`, `LOCAL_SQLITE_DB`.
107+
108+
### Deploy / migrations
109+
110+
Before deploy or `flask db upgrade`: `make alembic-guardrail` (or `python scripts/check_alembic_revision_guardrail.py`).
111+
112+
## Rule index
113+
114+
| Rule | Purpose |
115+
|------|---------|
116+
| `requirements-gate.mdc` | Clarifying questions + requirements template |
117+
| `complete-ticket.mdc` | Ticket gate for `.md`/`.txt` files; uses `requirements-gate` template + coding standards |
118+
| `plan-first-workflow.mdc` | Plan Mode before non-trivial edits |
119+
| `multi-agent-workflow.mdc` | Big changes, approval gates, builder ≠ judge |
120+
| `verifiable-goals.mdc` | Lint, mypy, test, CI — show evidence |
121+
| `never-assume.mdc` | No guessing; complete code; minimal scope |
122+
| `tdd-workflow.mdc` | Test-first for new behavior and importers |
123+
| `autonomous-workflow.mdc` | Execute after approval; no unsolicited commits |
124+
| `context-management.mdc` | `/clear`, `@` refs, stale context recovery |
125+
| `production-db-ops-safety.mdc` | Destructive prod DB confirmation |
126+
| `alembic-deploy-guardrail.mdc` | Pre-deploy migration guardrail |
127+
128+
## OpenCRE commands
129+
130+
```bash
131+
make lint # black + frontend prettier
132+
make mypy # Python typecheck
133+
make test # Python unittest suite
134+
make frontend # yarn build (when TS/TSX changed)
135+
make alembic-guardrail # before deploy/migration ops
136+
```

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ verify-ga-complete-prod:
153153
--base-url "https://opencre.org" \
154154
--output-json "$(CURDIR)/tmp/prod-ga-completeness.json"
155155

156+
monitor-ga-health-prod:
157+
@[ -d "./.venv" ] && . ./.venv/bin/activate || ([ -d "./venv" ] && . ./venv/bin/activate); \
158+
python scripts/monitor_ga_health.py \
159+
--base-url "https://opencre.org" \
160+
--output-json "$(CURDIR)/tmp/prod-ga-health.json"
161+
156162
verify-ga-parity-local:
157163
@[ -d "./.venv" ] && . ./.venv/bin/activate || ([ -d "./venv" ] && . ./venv/bin/activate); \
158164
export CRE_CACHE_FILE="$${CRE_CACHE_FILE:-postgresql://cre:password@127.0.0.1:5432/cre}"; \

application/database/db.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
make_resources_key,
4848
make_subresources_key,
4949
primary_gap_analysis_payload_is_material,
50+
should_persist_primary_gap_analysis_cache,
5051
)
5152

5253

@@ -2428,6 +2429,22 @@ def add_gap_analysis_result(self, cache_key: str, ga_object: str):
24282429
.filter(GapAnalysisResults.cache_key == cache_key)
24292430
.first()
24302431
)
2432+
if gap_analysis_cache_key_is_primary(cache_key):
2433+
existing_payload = existing.ga_object if existing else None
2434+
if not should_persist_primary_gap_analysis_cache(
2435+
ga_object, existing_payload
2436+
):
2437+
if existing is None:
2438+
logger.info(
2439+
"Skipping empty primary gap analysis cache insert for %s",
2440+
cache_key,
2441+
)
2442+
else:
2443+
logger.warning(
2444+
"Refusing non-material primary gap analysis update for %s",
2445+
cache_key,
2446+
)
2447+
return
24312448
if existing:
24322449
existing.ga_object = ga_object
24332450
self.session.add(existing)

application/tests/db_test.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,15 +1327,73 @@ def test_gap_analysis_paths_without_base_standard_nodes(self, gap_mock):
13271327
collection.gap_analysis_exists(make_resources_key(["788-788", "788-789"])),
13281328
)
13291329

1330+
def test_add_gap_analysis_result_skips_empty_primary_insert(self):
1331+
collection = db.Node_collection()
1332+
key = make_resources_key(["788-788", "b"])
1333+
collection.add_gap_analysis_result(key, '{"result": {}}')
1334+
self.assertIsNone(
1335+
collection.session.query(db.GapAnalysisResults)
1336+
.filter(db.GapAnalysisResults.cache_key == key)
1337+
.first()
1338+
)
1339+
self.assertFalse(collection.gap_analysis_exists(key))
1340+
1341+
def test_add_gap_analysis_result_does_not_clobber_material_primary(self):
1342+
collection = db.Node_collection()
1343+
key = make_resources_key(["788-788", "b"])
1344+
material = '{"result": {"111-111": {"paths": {}}}}'
1345+
collection.add_gap_analysis_result(key, material)
1346+
collection.add_gap_analysis_result(key, '{"result": {}}')
1347+
row = (
1348+
collection.session.query(db.GapAnalysisResults)
1349+
.filter(db.GapAnalysisResults.cache_key == key)
1350+
.first()
1351+
)
1352+
self.assertEqual(row.ga_object, material)
1353+
self.assertTrue(collection.gap_analysis_exists(key))
1354+
1355+
def test_add_gap_analysis_result_allows_material_over_empty_primary(self):
1356+
collection = db.Node_collection()
1357+
key = make_resources_key(["788-788", "b"])
1358+
collection.session.add(
1359+
db.GapAnalysisResults(cache_key=key, ga_object='{"result": {}}')
1360+
)
1361+
collection.session.commit()
1362+
material = '{"result": {"111-111": {"paths": {}}}}'
1363+
collection.add_gap_analysis_result(key, material)
1364+
row = (
1365+
collection.session.query(db.GapAnalysisResults)
1366+
.filter(db.GapAnalysisResults.cache_key == key)
1367+
.first()
1368+
)
1369+
self.assertEqual(row.ga_object, material)
1370+
1371+
def test_add_gap_analysis_result_allows_empty_subresource(self):
1372+
collection = db.Node_collection()
1373+
sub = make_subresources_key(["788-788", "b"], "111-111")
1374+
collection.add_gap_analysis_result(sub, '{"result": {}}')
1375+
row = (
1376+
collection.session.query(db.GapAnalysisResults)
1377+
.filter(db.GapAnalysisResults.cache_key == sub)
1378+
.first()
1379+
)
1380+
self.assertIsNotNone(row)
1381+
self.assertEqual(row.ga_object, '{"result": {}}')
1382+
13301383
@patch.object(db.NEO_DB, "gap_analysis")
13311384
def test_gap_analysis_removes_stale_empty_primary_when_neo_empty(self, gap_mock):
13321385
"""Placeholder ``{"result":{}}`` rows must not survive a recompute with no Neo paths."""
13331386
collection = db.Node_collection()
13341387
collection.neo_db.connected = True
13351388
key = make_resources_key(["788-788", "b"])
1336-
collection.add_gap_analysis_result(key, '{"result": {}}')
1389+
collection.session.add(
1390+
db.GapAnalysisResults(cache_key=key, ga_object='{"result": {}}')
1391+
)
13371392
sub = make_subresources_key(["788-788", "b"], "111-111")
1338-
collection.add_gap_analysis_result(sub, '{"result": {}}')
1393+
collection.session.add(
1394+
db.GapAnalysisResults(cache_key=sub, ga_object='{"result": {}}')
1395+
)
1396+
collection.session.commit()
13391397
self.assertFalse(collection.gap_analysis_exists(key))
13401398

13411399
gap_mock.return_value = ([], [])

application/tests/ga_parity_test.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ def test_empty_result_json_not_material(self):
3535
def test_whitespace_only_tags_not_material(self):
3636
self.assertFalse(gap_analysis.primary_gap_analysis_payload_is_material(" "))
3737

38+
def test_primary_cache_key_ignores_arrow_in_importing_name(self):
39+
self.assertTrue(
40+
gap_analysis.gap_analysis_cache_key_is_primary("A->B >> Compare")
41+
)
42+
self.assertFalse(
43+
gap_analysis.gap_analysis_cache_key_is_primary(
44+
"A >> Compare->node-section-id"
45+
)
46+
)
47+
3848

3949
if __name__ == "__main__":
4050
unittest.main()

application/tests/gap_analysis_pair_job_test.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ def get_gap_analysis_result(self, cache_key: str):
2727

2828

2929
class TestGapAnalysisPairJob(unittest.TestCase):
30+
def test_should_persist_primary_gap_analysis_cache(self):
31+
g = gap_analysis
32+
self.assertFalse(
33+
g.should_persist_primary_gap_analysis_cache('{"result":{}}', None)
34+
)
35+
self.assertFalse(
36+
g.should_persist_primary_gap_analysis_cache(
37+
'{"result":{}}', '{"result":{"k":1}}'
38+
)
39+
)
40+
self.assertTrue(
41+
g.should_persist_primary_gap_analysis_cache(
42+
'{"result":{"k":1}}', '{"result":{}}'
43+
)
44+
)
45+
self.assertTrue(
46+
g.should_persist_primary_gap_analysis_cache('{"result":{"k":1}}', None)
47+
)
48+
3049
def test_primary_gap_analysis_payload_is_material(self):
3150
g = gap_analysis
3251
self.assertFalse(g.primary_gap_analysis_payload_is_material(None))
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import io
2+
import unittest
3+
import urllib.error
4+
from unittest import mock
5+
6+
from scripts import monitor_ga_health as monitor
7+
8+
9+
class MonitorGaHealthTest(unittest.TestCase):
10+
def test_material_result_detection(self) -> None:
11+
self.assertTrue(monitor._http_gap_result_is_material({"a": 1}))
12+
self.assertFalse(monitor._http_gap_result_is_material({}))
13+
self.assertFalse(monitor._http_gap_result_is_material(None))
14+
15+
def test_check_pair_503_uses_regression_bucket(self) -> None:
16+
body = b"Service Unavailable"
17+
18+
def _raise_503(*_args, **_kwargs):
19+
raise urllib.error.HTTPError(
20+
"http://example", 503, "Service Unavailable", {}, io.BytesIO(body)
21+
)
22+
23+
with mock.patch("urllib.request.urlopen", side_effect=_raise_503):
24+
result = monitor._check_pair("https://opencre.org/rest/v1", "A", "B", 10)
25+
26+
self.assertIsNotNone(result)
27+
assert result is not None
28+
self.assertEqual(result["bucket"], "http_503_regression")
29+
self.assertEqual(result["status_code"], 503)
30+
31+
32+
if __name__ == "__main__":
33+
unittest.main()

0 commit comments

Comments
 (0)