@jaguilar87/gaia 5.0.8 → 5.0.9
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +341 -238
- package/bin/cli/brief.py +13 -0
- package/bin/cli/doctor.py +1 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-ops/hooks/post_compact.py +1 -0
- package/dist/gaia-ops/hooks/pre_compact.py +1 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
- package/dist/gaia-ops/tools/migration/README.md +10 -12
- package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/store.py +87 -9
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +19 -85
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/context/context_injector.py +23 -7
- package/hooks/modules/events/event_writer.py +63 -96
- package/hooks/modules/security/__init__.py +0 -2
- package/hooks/modules/security/approval_cleanup.py +238 -69
- package/hooks/modules/security/approval_grants.py +506 -1103
- package/hooks/modules/security/mutative_verbs.py +24 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/post_compact.py +1 -0
- package/hooks/pre_compact.py +1 -0
- package/hooks/user_prompt_submit.py +20 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/bootstrap_database.sh +66 -17
- package/scripts/migrations/README.md +26 -14
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v18_to_v19.sql +36 -0
- package/scripts/migrations/v19_to_v20.sql +20 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -7
- package/skills/agent-approval-protocol/reference.md +11 -6
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +10 -5
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +20 -6
- package/skills/subagent-request-approval/reference.md +23 -15
- package/tools/migration/README.md +10 -12
- package/tools/scan/orchestrator.py +194 -10
- package/tools/scan/tests/test_integration.py +1 -2
- package/bin/cli/plans.py +0 -517
- package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
- package/dist/gaia-ops/tools/scan/merge.py +0 -213
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
- package/tools/context/deep_merge.py +0 -159
- package/tools/migration/migrate_04_harness_events.py +0 -132
- package/tools/migration/migrate_04_harness_events.sh +0 -23
- package/tools/scan/merge.py +0 -213
- package/tools/scan/tests/test_merge.py +0 -269
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
migrate_04_harness_events.py
|
|
4
|
-
|
|
5
|
-
Convierte events.jsonl -> archivo SQL con INSERT batched.
|
|
6
|
-
|
|
7
|
-
Reglas:
|
|
8
|
-
- Solo I/O sobre filesystem.
|
|
9
|
-
- NO importa sqlite3.
|
|
10
|
-
- `id` es AUTOINCREMENT en la tabla; NO lo insertamos.
|
|
11
|
-
- `payload` = json.dumps(record) entero -- preserva todos los campos.
|
|
12
|
-
- Idempotencia: harness_events NO tiene UNIQUE constraint útil.
|
|
13
|
-
Aplicar una sola vez. Re-ejecuciones requieren DELETE WHERE project=...
|
|
14
|
-
|
|
15
|
-
CLI args (parametrización cross-workspace):
|
|
16
|
-
--project workspace name (default: 'me')
|
|
17
|
-
--src path al events.jsonl (default: ws/me)
|
|
18
|
-
--out path al SQL de salida (default: /tmp/migrate_04_harness_events.sql)
|
|
19
|
-
--fragment emite solo INSERTs (sin BEGIN/COMMIT)
|
|
20
|
-
"""
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import argparse
|
|
24
|
-
import json
|
|
25
|
-
import sys
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
|
|
28
|
-
DEFAULT_PROJECT = "me"
|
|
29
|
-
DEFAULT_SRC = Path("/home/jorge/ws/me/.claude/events/events.jsonl")
|
|
30
|
-
DEFAULT_OUT = Path("/tmp/migrate_04_harness_events.sql")
|
|
31
|
-
BATCH_SIZE = 200
|
|
32
|
-
|
|
33
|
-
COLUMNS = ["project", "ts", "type", "source", "agent", "result", "severity", "payload"]
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def sql_quote(value) -> str:
|
|
37
|
-
if value is None:
|
|
38
|
-
return "NULL"
|
|
39
|
-
if isinstance(value, bool):
|
|
40
|
-
return "1" if value else "0"
|
|
41
|
-
if isinstance(value, (int, float)):
|
|
42
|
-
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
43
|
-
return "NULL"
|
|
44
|
-
return str(value)
|
|
45
|
-
s = str(value)
|
|
46
|
-
return "'" + s.replace("'", "''") + "'"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def extract_row(record: dict, project: str) -> dict:
|
|
50
|
-
return {
|
|
51
|
-
"project": project,
|
|
52
|
-
"ts": record.get("ts"),
|
|
53
|
-
"type": record.get("type"),
|
|
54
|
-
"source": record.get("source"),
|
|
55
|
-
"agent": record.get("agent"),
|
|
56
|
-
"result": record.get("result"),
|
|
57
|
-
"severity": record.get("severity"),
|
|
58
|
-
"payload": json.dumps(record, ensure_ascii=False, separators=(",", ":")),
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def row_values_sql(row: dict) -> str:
|
|
63
|
-
return "(" + ",".join(sql_quote(row.get(col)) for col in COLUMNS) + ")"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def main() -> int:
|
|
67
|
-
parser = argparse.ArgumentParser(description="Generate INSERT SQL for harness_events table.")
|
|
68
|
-
parser.add_argument("--project", default=DEFAULT_PROJECT)
|
|
69
|
-
parser.add_argument("--src", default=str(DEFAULT_SRC), help="path to events.jsonl")
|
|
70
|
-
parser.add_argument("--out", default=str(DEFAULT_OUT))
|
|
71
|
-
parser.add_argument("--fragment", action="store_true")
|
|
72
|
-
args = parser.parse_args()
|
|
73
|
-
|
|
74
|
-
project = args.project
|
|
75
|
-
src = Path(args.src)
|
|
76
|
-
out = Path(args.out)
|
|
77
|
-
fragment = args.fragment
|
|
78
|
-
|
|
79
|
-
if not src.exists():
|
|
80
|
-
print(f"[migrate_04:{project}] ERROR: source not found: {src}", file=sys.stderr)
|
|
81
|
-
return 1
|
|
82
|
-
|
|
83
|
-
rows = []
|
|
84
|
-
skipped = 0
|
|
85
|
-
total_lines = 0
|
|
86
|
-
|
|
87
|
-
with src.open("r", encoding="utf-8") as f:
|
|
88
|
-
for line in f:
|
|
89
|
-
total_lines += 1
|
|
90
|
-
s = line.strip()
|
|
91
|
-
if not s:
|
|
92
|
-
continue
|
|
93
|
-
try:
|
|
94
|
-
rec = json.loads(s)
|
|
95
|
-
except json.JSONDecodeError:
|
|
96
|
-
skipped += 1
|
|
97
|
-
continue
|
|
98
|
-
if not rec.get("ts") or not rec.get("type"):
|
|
99
|
-
skipped += 1
|
|
100
|
-
continue
|
|
101
|
-
rows.append(extract_row(rec, project))
|
|
102
|
-
|
|
103
|
-
cols_csv = ",".join(COLUMNS)
|
|
104
|
-
insert_prefix = f"INSERT INTO harness_events ({cols_csv}) VALUES\n"
|
|
105
|
-
|
|
106
|
-
with out.open("w", encoding="utf-8") as fh:
|
|
107
|
-
fh.write(f"-- Generated by migrate_04_harness_events.py\n")
|
|
108
|
-
fh.write(f"-- Project: {project}\n")
|
|
109
|
-
fh.write(f"-- Source: {src}\n")
|
|
110
|
-
fh.write(f"-- Total source lines: {total_lines}\n")
|
|
111
|
-
fh.write(f"-- Records to insert: {len(rows)}\n")
|
|
112
|
-
fh.write(f"-- Skipped: {skipped}\n")
|
|
113
|
-
fh.write("--\n")
|
|
114
|
-
fh.write("-- WARNING: harness_events sin PK natural; aplicar 2 veces duplica filas.\n")
|
|
115
|
-
if not fragment:
|
|
116
|
-
fh.write("BEGIN TRANSACTION;\n")
|
|
117
|
-
|
|
118
|
-
for i in range(0, len(rows), BATCH_SIZE):
|
|
119
|
-
batch = rows[i : i + BATCH_SIZE]
|
|
120
|
-
fh.write(insert_prefix)
|
|
121
|
-
fh.write(",\n".join(row_values_sql(r) for r in batch))
|
|
122
|
-
fh.write(";\n")
|
|
123
|
-
|
|
124
|
-
if not fragment:
|
|
125
|
-
fh.write("COMMIT;\n")
|
|
126
|
-
|
|
127
|
-
print(f"[migrate_04:{project}] wrote {out} ({len(rows)} rows, {skipped} skipped)")
|
|
128
|
-
return 0
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if __name__ == "__main__":
|
|
132
|
-
sys.exit(main())
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# migrate_04_harness_events.sh
|
|
3
|
-
# Wrapper: regenera el .sql desde events.jsonl y lo carga en ~/.gaia/gaia.db.
|
|
4
|
-
#
|
|
5
|
-
# OJO: harness_events no tiene PK natural. Re-ejecutar este wrapper duplica
|
|
6
|
-
# filas. Si necesitas re-ejecutar limpio, primero elimina las filas con:
|
|
7
|
-
# sqlite3 ~/.gaia/gaia.db "DELETE FROM harness_events WHERE project='me';"
|
|
8
|
-
set -euo pipefail
|
|
9
|
-
|
|
10
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
-
PY_SCRIPT="${HERE}/migrate_04_harness_events.py"
|
|
12
|
-
SQL_FILE="/tmp/migrate_04_harness_events.sql"
|
|
13
|
-
DB_PATH="${HOME}/.gaia/gaia.db"
|
|
14
|
-
|
|
15
|
-
# Paso 1: regenerar el .sql.
|
|
16
|
-
echo "[migrate_04] regenerando ${SQL_FILE} ..."
|
|
17
|
-
python3 "${PY_SCRIPT}"
|
|
18
|
-
|
|
19
|
-
# Paso 2: aplicar el SQL (interceptado por el hook).
|
|
20
|
-
echo "[migrate_04] aplicando ${SQL_FILE} en ${DB_PATH} ..."
|
|
21
|
-
sqlite3 "${DB_PATH}" < "${SQL_FILE}"
|
|
22
|
-
|
|
23
|
-
echo "[migrate_04] OK"
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Section-Aware Context Combining Logic
|
|
3
|
-
|
|
4
|
-
Merges scanner sections in-memory following the merge rules from
|
|
5
|
-
contracts/merge-behavior.md. No file I/O -- gaia.db is the sole
|
|
6
|
-
persistence layer.
|
|
7
|
-
|
|
8
|
-
Rule 1: Scanner-owned sections -> full replace
|
|
9
|
-
Rule 2: Agent-enriched sections -> never touch
|
|
10
|
-
Rule 3: Mixed sections -> selective update at sub-key level
|
|
11
|
-
Rule 4: Unknown/user-custom sections -> preserve
|
|
12
|
-
Rule 5: Metadata -> always update
|
|
13
|
-
|
|
14
|
-
Note: Backward-compatible sections (project_details, application_architecture,
|
|
15
|
-
development_standards) were removed in v3. Consumers read v2 scanner sections
|
|
16
|
-
directly (project_identity, stack, git, environment, infrastructure).
|
|
17
|
-
|
|
18
|
-
Special handling for sub-section level ownership: the `environment` section
|
|
19
|
-
is jointly owned by the `tools` scanner (tools, tool_preferences) and the
|
|
20
|
-
`environment` scanner (os, runtimes, env_files). Each scanner replaces only
|
|
21
|
-
its owned sub-keys without clobbering the other scanner's sub-keys.
|
|
22
|
-
|
|
23
|
-
Contract: specs/002-gaia-scan/contracts/merge-behavior.md
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
import copy
|
|
27
|
-
import logging
|
|
28
|
-
from typing import Any, Dict, Optional, Set
|
|
29
|
-
|
|
30
|
-
from tools.context.deep_merge import deep_merge
|
|
31
|
-
|
|
32
|
-
logger = logging.getLogger(__name__)
|
|
33
|
-
|
|
34
|
-
# Sections fully owned by scanners -- replaced entirely on each scan (Rule 1)
|
|
35
|
-
# Top-level sections only; sub-key ownership handled separately
|
|
36
|
-
SCANNER_OWNED_TOP_LEVEL: Dict[str, str] = {
|
|
37
|
-
"project_identity": "stack",
|
|
38
|
-
"stack": "stack",
|
|
39
|
-
"git": "git",
|
|
40
|
-
"infrastructure": "infrastructure",
|
|
41
|
-
"orchestration": "orchestration",
|
|
42
|
-
# "environment" is NOT listed here because it has sub-key ownership
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
# Sub-key ownership within the `environment` section (Rule 4 / sub-section)
|
|
46
|
-
# Maps environment sub-key -> owning scanner name
|
|
47
|
-
ENVIRONMENT_SUBKEY_OWNERS: Dict[str, str] = {
|
|
48
|
-
"tools": "tools",
|
|
49
|
-
"tool_preferences": "tools",
|
|
50
|
-
"os": "environment",
|
|
51
|
-
"runtimes": "environment",
|
|
52
|
-
"env_files": "environment",
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
# Agent-enriched sections -- never modified by scanners (Rule 2)
|
|
56
|
-
AGENT_ENRICHED_SECTIONS: frozenset = frozenset([
|
|
57
|
-
"operational_guidelines",
|
|
58
|
-
"cluster_details",
|
|
59
|
-
"infrastructure_topology",
|
|
60
|
-
"monitoring_observability",
|
|
61
|
-
"architecture_overview",
|
|
62
|
-
"gcp_services",
|
|
63
|
-
"workload_identity",
|
|
64
|
-
])
|
|
65
|
-
|
|
66
|
-
# Mixed sections with partial scanner ownership (Rule 3)
|
|
67
|
-
# Maps section_name -> set of scanner-owned field names
|
|
68
|
-
MIXED_SECTION_SCANNER_FIELDS: Dict[str, Set[str]] = {
|
|
69
|
-
"terraform_infrastructure": {"layout"},
|
|
70
|
-
"gitops_configuration": {"repository"},
|
|
71
|
-
"application_services": {"base_path", "services"},
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def merge_context(
|
|
76
|
-
existing: Dict[str, Any],
|
|
77
|
-
scan_sections: Dict[str, Any],
|
|
78
|
-
section_owners: Dict[str, str],
|
|
79
|
-
) -> Dict[str, Any]:
|
|
80
|
-
"""Merge scanner results with existing project-context sections.
|
|
81
|
-
|
|
82
|
-
Applies the merge rules from contracts/merge-behavior.md to produce
|
|
83
|
-
the final merged sections dict.
|
|
84
|
-
|
|
85
|
-
Args:
|
|
86
|
-
existing: Current sections from project-context.json (may be empty).
|
|
87
|
-
scan_sections: Combined sections from all scanners.
|
|
88
|
-
section_owners: Mapping of section/sub-section name to scanner name,
|
|
89
|
-
from ScannerRegistry.get_section_owners().
|
|
90
|
-
|
|
91
|
-
Returns:
|
|
92
|
-
Merged sections dict ready to be written to project-context.json.
|
|
93
|
-
The merge is deterministic: same inputs always produce the same output.
|
|
94
|
-
"""
|
|
95
|
-
result = copy.deepcopy(existing)
|
|
96
|
-
|
|
97
|
-
# --- Rule 1: Scanner-owned top-level sections -> full replace ---
|
|
98
|
-
for section_name in SCANNER_OWNED_TOP_LEVEL:
|
|
99
|
-
if section_name in scan_sections:
|
|
100
|
-
result[section_name] = copy.deepcopy(scan_sections[section_name])
|
|
101
|
-
|
|
102
|
-
# --- Sub-section level ownership for `environment` ---
|
|
103
|
-
# Both the `tools` scanner and `environment` scanner contribute sub-keys
|
|
104
|
-
# to the `environment` section. Each scanner's sub-keys replace their owned
|
|
105
|
-
# portion without clobbering the other scanner's sub-keys.
|
|
106
|
-
_merge_environment_section(result, scan_sections)
|
|
107
|
-
|
|
108
|
-
# --- Rule 2: Agent-enriched sections -> never touch ---
|
|
109
|
-
# These are already in `result` from the deepcopy of `existing`.
|
|
110
|
-
# We explicitly do NOT overwrite them, even if a scanner accidentally
|
|
111
|
-
# produced data for one of these section names.
|
|
112
|
-
# (No action needed -- they are preserved by the deepcopy.)
|
|
113
|
-
|
|
114
|
-
# --- Rule 3: Mixed sections -> selective update ---
|
|
115
|
-
for section_name, scanner_fields in MIXED_SECTION_SCANNER_FIELDS.items():
|
|
116
|
-
if section_name in scan_sections:
|
|
117
|
-
scan_data = scan_sections[section_name]
|
|
118
|
-
if section_name not in result:
|
|
119
|
-
result[section_name] = {}
|
|
120
|
-
# Only update scanner-owned fields; preserve agent fields
|
|
121
|
-
for field_name in scanner_fields:
|
|
122
|
-
if field_name in scan_data:
|
|
123
|
-
result[section_name][field_name] = copy.deepcopy(
|
|
124
|
-
scan_data[field_name]
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
# --- Rule 5: Unknown/user-custom sections -> preserve ---
|
|
128
|
-
# Any section in `existing` that is not scanner-owned, not agent-enriched,
|
|
129
|
-
# not backward-compat, and not mixed is a user-custom section.
|
|
130
|
-
# These are already preserved by the initial deepcopy of `existing`.
|
|
131
|
-
# We do NOT add new unknown sections from scan_sections.
|
|
132
|
-
|
|
133
|
-
return result
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _merge_environment_section(
|
|
137
|
-
result: Dict[str, Any],
|
|
138
|
-
scan_sections: Dict[str, Any],
|
|
139
|
-
) -> None:
|
|
140
|
-
"""Merge the `environment` section with sub-key level ownership.
|
|
141
|
-
|
|
142
|
-
Two scanners contribute to the `environment` section:
|
|
143
|
-
- `tools` scanner owns: tools, tool_preferences
|
|
144
|
-
- `environment` scanner owns: os, runtimes, env_files
|
|
145
|
-
|
|
146
|
-
Each scanner's sub-keys replace their owned portion; the other scanner's
|
|
147
|
-
sub-keys are preserved. The `_source` field gets a combined tag.
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
result: The result dict being built (mutated in place).
|
|
151
|
-
scan_sections: Combined sections from all scanners.
|
|
152
|
-
"""
|
|
153
|
-
if "environment" not in scan_sections:
|
|
154
|
-
return
|
|
155
|
-
|
|
156
|
-
scan_env = scan_sections["environment"]
|
|
157
|
-
|
|
158
|
-
if "environment" not in result:
|
|
159
|
-
result["environment"] = {}
|
|
160
|
-
|
|
161
|
-
env = result["environment"]
|
|
162
|
-
|
|
163
|
-
# Replace each sub-key based on ownership
|
|
164
|
-
for subkey in ENVIRONMENT_SUBKEY_OWNERS:
|
|
165
|
-
if subkey in scan_env:
|
|
166
|
-
env[subkey] = copy.deepcopy(scan_env[subkey])
|
|
167
|
-
|
|
168
|
-
# Set combined _source tag
|
|
169
|
-
env["_source"] = "scanner:environment+tools"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def collect_scanner_sections(
|
|
173
|
-
scanner_results: Dict[str, Any],
|
|
174
|
-
) -> Dict[str, Any]:
|
|
175
|
-
"""Collect and combine sections from all scanner results.
|
|
176
|
-
|
|
177
|
-
Handles the environment section specially: both `tools` and `environment`
|
|
178
|
-
scanners produce sub-keys under `environment`, so their outputs are
|
|
179
|
-
combined into a single `environment` section.
|
|
180
|
-
|
|
181
|
-
Args:
|
|
182
|
-
scanner_results: Mapping of scanner_name -> ScanResult (must have
|
|
183
|
-
a `sections` attribute that is a dict).
|
|
184
|
-
|
|
185
|
-
Returns:
|
|
186
|
-
Combined sections dict from all scanners.
|
|
187
|
-
"""
|
|
188
|
-
combined: Dict[str, Any] = {}
|
|
189
|
-
environment_parts: Dict[str, Any] = {}
|
|
190
|
-
|
|
191
|
-
for _scanner_name, scan_result in scanner_results.items():
|
|
192
|
-
sections = scan_result.sections if hasattr(scan_result, "sections") else {}
|
|
193
|
-
|
|
194
|
-
for section_name, section_data in sections.items():
|
|
195
|
-
if section_name == "environment":
|
|
196
|
-
# Merge environment sub-keys from both scanners
|
|
197
|
-
if isinstance(section_data, dict):
|
|
198
|
-
for key, value in section_data.items():
|
|
199
|
-
if key != "_source":
|
|
200
|
-
environment_parts[key] = value
|
|
201
|
-
else:
|
|
202
|
-
# Non-environment sections: direct assignment (last scanner wins,
|
|
203
|
-
# but each section should have exactly one owner)
|
|
204
|
-
combined[section_name] = section_data
|
|
205
|
-
|
|
206
|
-
# Reassemble environment section if we got any parts
|
|
207
|
-
if environment_parts:
|
|
208
|
-
combined["environment"] = {
|
|
209
|
-
"_source": "scanner:environment+tools",
|
|
210
|
-
**environment_parts,
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return combined
|
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for context combining logic (T026).
|
|
3
|
-
|
|
4
|
-
Tests scanner-owned section replacement, agent-enriched section preservation,
|
|
5
|
-
mixed section sub-key merge, unknown section preservation, v1-to-v2 upgrade,
|
|
6
|
-
and idempotency.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import copy
|
|
10
|
-
import json
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any, Dict
|
|
13
|
-
|
|
14
|
-
import pytest
|
|
15
|
-
|
|
16
|
-
from tools.scan.merge import merge_context
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
# ---------------------------------------------------------------------------
|
|
20
|
-
# Helpers
|
|
21
|
-
# ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _section_owners() -> Dict[str, str]:
|
|
25
|
-
"""Return a realistic section_owners map from ScannerRegistry."""
|
|
26
|
-
return {
|
|
27
|
-
"project_identity": "stack",
|
|
28
|
-
"stack": "stack",
|
|
29
|
-
"git": "git",
|
|
30
|
-
"infrastructure": "infrastructure",
|
|
31
|
-
"orchestration": "orchestration",
|
|
32
|
-
"environment.tools": "tools",
|
|
33
|
-
"environment.tool_preferences": "tools",
|
|
34
|
-
"environment.runtimes": "environment",
|
|
35
|
-
"environment.os": "environment",
|
|
36
|
-
"environment.env_files": "environment",
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# ---------------------------------------------------------------------------
|
|
41
|
-
# Test data helpers
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _make_existing_context() -> Dict[str, Any]:
|
|
46
|
-
"""Create a realistic existing project-context with scanner + agent data."""
|
|
47
|
-
return {
|
|
48
|
-
"metadata": {
|
|
49
|
-
"version": "2.0",
|
|
50
|
-
"last_updated": "2026-01-01T00:00:00Z",
|
|
51
|
-
"project_name": "test-project",
|
|
52
|
-
"scan_config": {
|
|
53
|
-
"staleness_hours": 24,
|
|
54
|
-
"last_scan": "2026-01-01T00:00:00Z",
|
|
55
|
-
"scanner_version": "0.1.0",
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
"project_identity": {
|
|
59
|
-
"_source": "scanner:stack",
|
|
60
|
-
"name": "old-name",
|
|
61
|
-
"type": "application",
|
|
62
|
-
},
|
|
63
|
-
"stack": {
|
|
64
|
-
"_source": "scanner:stack",
|
|
65
|
-
"languages": [{"name": "python", "manifest": "pyproject.toml", "primary": True}],
|
|
66
|
-
"frameworks": [],
|
|
67
|
-
"build_tools": [],
|
|
68
|
-
},
|
|
69
|
-
"git": {
|
|
70
|
-
"_source": "scanner:git",
|
|
71
|
-
"platform": "github",
|
|
72
|
-
"remotes": [],
|
|
73
|
-
"default_branch": "main",
|
|
74
|
-
},
|
|
75
|
-
"environment": {
|
|
76
|
-
"_source": "scanner:environment",
|
|
77
|
-
"os": {"platform": "linux", "architecture": "x64"},
|
|
78
|
-
"runtimes": [{"name": "python3", "version": "3.11.0"}],
|
|
79
|
-
"env_files": [],
|
|
80
|
-
"tools": [{"name": "git", "path": "/usr/bin/git"}],
|
|
81
|
-
"tool_preferences": {"file_viewer": "bat"},
|
|
82
|
-
},
|
|
83
|
-
"operational_guidelines": {
|
|
84
|
-
"_source": "agent:developer",
|
|
85
|
-
"deployment_strategy": "blue-green",
|
|
86
|
-
"rollback_procedure": "manual",
|
|
87
|
-
},
|
|
88
|
-
"my_custom_notes": {
|
|
89
|
-
"author": "user",
|
|
90
|
-
"notes": "User-maintained section",
|
|
91
|
-
},
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _make_scan_results() -> Dict[str, Any]:
|
|
96
|
-
"""Create scan results from a new scan run."""
|
|
97
|
-
return {
|
|
98
|
-
"project_identity": {
|
|
99
|
-
"_source": "scanner:stack",
|
|
100
|
-
"name": "new-name",
|
|
101
|
-
"type": "monorepo",
|
|
102
|
-
"description": "Updated description",
|
|
103
|
-
},
|
|
104
|
-
"stack": {
|
|
105
|
-
"_source": "scanner:stack",
|
|
106
|
-
"languages": [
|
|
107
|
-
{"name": "typescript", "manifest": "package.json", "primary": True},
|
|
108
|
-
{"name": "python", "manifest": "pyproject.toml", "primary": False},
|
|
109
|
-
],
|
|
110
|
-
"frameworks": [{"name": "react", "language": "typescript"}],
|
|
111
|
-
"build_tools": [{"name": "npm", "detected_by": "lock_file"}],
|
|
112
|
-
},
|
|
113
|
-
"git": {
|
|
114
|
-
"_source": "scanner:git",
|
|
115
|
-
"platform": "github",
|
|
116
|
-
"remotes": [{"name": "origin", "url": "git@github.com:o/r.git"}],
|
|
117
|
-
"default_branch": "main",
|
|
118
|
-
},
|
|
119
|
-
"environment": {
|
|
120
|
-
"_source": "scanner:environment",
|
|
121
|
-
"os": {"platform": "linux", "architecture": "x64", "wsl": True},
|
|
122
|
-
"runtimes": [{"name": "python3", "version": "3.12.0"}],
|
|
123
|
-
"env_files": [{"name": ".env", "path": ".env"}],
|
|
124
|
-
},
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
# ---------------------------------------------------------------------------
|
|
129
|
-
# Rule 1: Scanner-owned section fully replaced
|
|
130
|
-
# ---------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class TestScannerOwnedSectionReplacement:
|
|
134
|
-
"""Test that scanner-owned sections are fully replaced with new data."""
|
|
135
|
-
|
|
136
|
-
def test_project_identity_replaced(self) -> None:
|
|
137
|
-
existing = _make_existing_context()
|
|
138
|
-
scan = _make_scan_results()
|
|
139
|
-
result = merge_context(existing, scan, _section_owners())
|
|
140
|
-
assert result["project_identity"]["name"] == "new-name"
|
|
141
|
-
assert result["project_identity"]["type"] == "monorepo"
|
|
142
|
-
|
|
143
|
-
def test_stack_section_replaced(self) -> None:
|
|
144
|
-
existing = _make_existing_context()
|
|
145
|
-
scan = _make_scan_results()
|
|
146
|
-
result = merge_context(existing, scan, _section_owners())
|
|
147
|
-
lang_names = [l["name"] for l in result["stack"]["languages"]]
|
|
148
|
-
assert "typescript" in lang_names
|
|
149
|
-
assert len(result["stack"]["frameworks"]) == 1
|
|
150
|
-
|
|
151
|
-
def test_git_section_replaced(self) -> None:
|
|
152
|
-
existing = _make_existing_context()
|
|
153
|
-
scan = _make_scan_results()
|
|
154
|
-
result = merge_context(existing, scan, _section_owners())
|
|
155
|
-
assert len(result["git"]["remotes"]) == 1
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# ---------------------------------------------------------------------------
|
|
159
|
-
# Rule 2: Agent-enriched sections preserved
|
|
160
|
-
# ---------------------------------------------------------------------------
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
class TestAgentEnrichedPreservation:
|
|
164
|
-
"""Test that agent-enriched sections are preserved byte-identical."""
|
|
165
|
-
|
|
166
|
-
def test_operational_guidelines_preserved(self) -> None:
|
|
167
|
-
existing = _make_existing_context()
|
|
168
|
-
scan = _make_scan_results()
|
|
169
|
-
result = merge_context(existing, scan, _section_owners())
|
|
170
|
-
assert result["operational_guidelines"]["deployment_strategy"] == "blue-green"
|
|
171
|
-
assert result["operational_guidelines"]["rollback_procedure"] == "manual"
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# ---------------------------------------------------------------------------
|
|
175
|
-
# Rule 4: Mixed section (environment) sub-key merge
|
|
176
|
-
# ---------------------------------------------------------------------------
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
class TestMixedSectionMerge:
|
|
180
|
-
"""Test that mixed sections merge scanner fields and keep agent fields."""
|
|
181
|
-
|
|
182
|
-
def test_environment_scanner_fields_refreshed(self) -> None:
|
|
183
|
-
existing = _make_existing_context()
|
|
184
|
-
scan = _make_scan_results()
|
|
185
|
-
result = merge_context(existing, scan, _section_owners())
|
|
186
|
-
# Scanner-owned sub-keys should be updated
|
|
187
|
-
assert result["environment"]["os"].get("wsl") is True
|
|
188
|
-
runtimes = result["environment"]["runtimes"]
|
|
189
|
-
py = [r for r in runtimes if r["name"] == "python3"][0]
|
|
190
|
-
assert py["version"] == "3.12.0"
|
|
191
|
-
|
|
192
|
-
def test_environment_agent_fields_kept(self) -> None:
|
|
193
|
-
existing = _make_existing_context()
|
|
194
|
-
scan = _make_scan_results()
|
|
195
|
-
result = merge_context(existing, scan, _section_owners())
|
|
196
|
-
# tools and tool_preferences came from tool scanner (not in this scan)
|
|
197
|
-
# They should be preserved from existing
|
|
198
|
-
assert "tools" in result["environment"] or "tool_preferences" in result["environment"]
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
# ---------------------------------------------------------------------------
|
|
202
|
-
# Rule 5: Unknown/user-custom sections preserved
|
|
203
|
-
# ---------------------------------------------------------------------------
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
class TestUserCustomPreservation:
|
|
207
|
-
"""Test that user-custom sections survive combining."""
|
|
208
|
-
|
|
209
|
-
def test_custom_section_preserved(self) -> None:
|
|
210
|
-
existing = _make_existing_context()
|
|
211
|
-
scan = _make_scan_results()
|
|
212
|
-
result = merge_context(existing, scan, _section_owners())
|
|
213
|
-
assert "my_custom_notes" in result
|
|
214
|
-
assert result["my_custom_notes"]["author"] == "user"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
# ---------------------------------------------------------------------------
|
|
218
|
-
# v1-to-v2 upgrade
|
|
219
|
-
# ---------------------------------------------------------------------------
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
class TestV1ToV2Upgrade:
|
|
223
|
-
"""Test upgrading from v1 project-context (no scan_config)."""
|
|
224
|
-
|
|
225
|
-
def test_v1_context_upgraded(self, sample_project_context_v1: Dict[str, Any]) -> None:
|
|
226
|
-
scan = _make_scan_results()
|
|
227
|
-
result = merge_context(sample_project_context_v1, scan, _section_owners())
|
|
228
|
-
# Should have scan_config after upgrade
|
|
229
|
-
assert "metadata" in result
|
|
230
|
-
# Agent-enriched data from v1 should be preserved
|
|
231
|
-
if "operational_guidelines" in sample_project_context_v1:
|
|
232
|
-
assert "operational_guidelines" in result
|
|
233
|
-
|
|
234
|
-
def test_v1_agent_data_not_lost(self, sample_project_context_v1: Dict[str, Any]) -> None:
|
|
235
|
-
scan = _make_scan_results()
|
|
236
|
-
result = merge_context(sample_project_context_v1, scan, _section_owners())
|
|
237
|
-
# User-custom sections from v1 are preserved as-is (Rule 4).
|
|
238
|
-
# project_details is no longer produced by the scanner (no backward compat),
|
|
239
|
-
# but if it existed in the v1 context it is preserved as a user-custom section.
|
|
240
|
-
if "project_details" in sample_project_context_v1:
|
|
241
|
-
assert "project_details" in result
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
# ---------------------------------------------------------------------------
|
|
245
|
-
# Idempotency
|
|
246
|
-
# ---------------------------------------------------------------------------
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
class TestIdempotency:
|
|
250
|
-
"""Test that running combine twice produces same result (except timestamps)."""
|
|
251
|
-
|
|
252
|
-
def test_idempotent_combine(self) -> None:
|
|
253
|
-
existing = _make_existing_context()
|
|
254
|
-
scan = _make_scan_results()
|
|
255
|
-
|
|
256
|
-
result1 = merge_context(existing, scan, _section_owners())
|
|
257
|
-
result2 = merge_context(result1, scan, _section_owners())
|
|
258
|
-
|
|
259
|
-
# Strip timestamps for comparison
|
|
260
|
-
def strip_timestamps(d: Dict) -> Dict:
|
|
261
|
-
d = copy.deepcopy(d)
|
|
262
|
-
if "metadata" in d:
|
|
263
|
-
meta = d["metadata"]
|
|
264
|
-
meta.pop("last_updated", None)
|
|
265
|
-
if "scan_config" in meta:
|
|
266
|
-
meta["scan_config"].pop("last_scan", None)
|
|
267
|
-
return d
|
|
268
|
-
|
|
269
|
-
assert strip_timestamps(result1) == strip_timestamps(result2)
|