Skip to content

Commit 70af687

Browse files
committed
feat(harvester): requirement extract and GitHub URL ingest
Optional requirement extract (off/auto/on) plus GitHub tarball/URL ingest wiring for Module B → C. Stacked on #1088 librarian base.
1 parent 7fe303e commit 70af687

21 files changed

Lines changed: 1801 additions & 21 deletions

‎application/cmd/cre_main.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,25 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
911911
sys.exit(1)
912912
return
913913

914+
if str(getattr(args, "ingest_github", "") or "").strip():
915+
import sys
916+
917+
from application import sqla
918+
from application.utils.harvester.github_url_ingest import ingest_github_url
919+
920+
db_connect(args.cache_file)
921+
counts = ingest_github_url(
922+
str(args.ingest_github).strip(),
923+
session=sqla.session,
924+
cache_file=args.cache_file,
925+
branch_override=(getattr(args, "ingest_branch", "") or "").strip() or None,
926+
keep_all=bool(getattr(args, "ingest_keep_all", False)),
927+
model_override=(getattr(args, "ingest_model", "") or "").strip() or None,
928+
)
929+
if counts.errors or (counts.knowledge + counts.uncertain) == 0:
930+
sys.exit(1)
931+
return
932+
914933
if getattr(args, "run_noise_filter", False):
915934
import sys
916935

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Tests for Module A.2 chunk merge profiles."""
2+
3+
from __future__ import annotations
4+
5+
import unittest
6+
from datetime import datetime, timezone
7+
8+
from application.utils.harvester.chunk_merger import merge_chunks, requirement_ids
9+
from application.utils.harvester.models import (
10+
ChunkInfo,
11+
Document,
12+
HeadingNode,
13+
Locator,
14+
SourceInfo,
15+
)
16+
from application.utils.harvester.schemas import ChunkingConfig
17+
18+
19+
def _doc(text: str, headings: list[HeadingNode] | None = None) -> Document:
20+
return Document(
21+
schema_version="0.2.0",
22+
artifact_id="art:OWASP/Test:x.md",
23+
pipeline_run_id="test",
24+
text=text,
25+
source=SourceInfo(
26+
type="github",
27+
repository="OWASP/Test",
28+
commit_sha="abc",
29+
committed_at=datetime.now(timezone.utc),
30+
),
31+
locator=Locator(kind="repo_path", id="x.md", path="x.md"),
32+
heading_structure=headings or [],
33+
)
34+
35+
36+
class ChunkMergerTests(unittest.TestCase):
37+
def test_requirement_ids_extracted(self) -> None:
38+
self.assertEqual(
39+
requirement_ids("**2.1.1** Verify passwords"), frozenset({"2.1.1"})
40+
)
41+
self.assertIn("2.1.1", requirement_ids("V2.1.1 something"))
42+
self.assertEqual(
43+
requirement_ids("AC-2 Account Management"), frozenset({"ac-2"})
44+
)
45+
self.assertIn("a.5.1", requirement_ids("ISO A.5.1 policies"))
46+
self.assertIn("3.4", requirement_ids("Req 3.4 Protect stored"))
47+
self.assertEqual(
48+
requirement_ids("Requirement 3.4.1 hashing"), frozenset({"3.4.1"})
49+
)
50+
51+
def test_requirements_does_not_merge_nist_or_iso_ids(self) -> None:
52+
t1 = ("AC-2 Account Management. " * 20).strip()
53+
t2 = ("AC-3 Access Enforcement. " * 20).strip()
54+
text = t1 + "\n\n" + t2
55+
mid = len(t1) + 2
56+
chunks = [
57+
ChunkInfo(text=t1, start_char_idx=0, end_char_idx=len(t1)),
58+
ChunkInfo(text=t2, start_char_idx=mid, end_char_idx=len(text)),
59+
]
60+
headings = [
61+
HeadingNode(level=2, text="Access Control", start_line=1, end_line=99)
62+
]
63+
doc = _doc(text, headings)
64+
cfg = ChunkingConfig(
65+
strategy="docling",
66+
max_tokens=1200,
67+
overlap_tokens=10,
68+
merge_profile="requirements",
69+
)
70+
out = merge_chunks(doc, chunks, cfg)
71+
self.assertEqual(len(out), 2)
72+
73+
def test_none_profile_is_noop(self) -> None:
74+
text = "aaaa " * 50 + "\n\n" + "bbbb " * 50
75+
mid = text.index("bbbb")
76+
chunks = [
77+
ChunkInfo(text=text[:mid], start_char_idx=0, end_char_idx=mid),
78+
ChunkInfo(text=text[mid:], start_char_idx=mid, end_char_idx=len(text)),
79+
]
80+
cfg = ChunkingConfig(
81+
strategy="docling", max_tokens=1200, overlap_tokens=10, merge_profile="none"
82+
)
83+
doc = _doc(text)
84+
out = merge_chunks(doc, chunks, cfg)
85+
self.assertEqual(len(out), 2)
86+
87+
def test_requirements_does_not_merge_across_ids(self) -> None:
88+
t1 = ("Verify 2.1.1 passwords are long. " * 20).strip()
89+
t2 = ("Verify 2.1.2 passwords allow unicode. " * 20).strip()
90+
text = t1 + "\n\n" + t2
91+
mid = len(t1) + 2
92+
chunks = [
93+
ChunkInfo(text=t1, start_char_idx=0, end_char_idx=len(t1)),
94+
ChunkInfo(text=t2, start_char_idx=mid, end_char_idx=len(text)),
95+
]
96+
headings = [
97+
HeadingNode(
98+
level=2, text="V2.1 Password Security", start_line=1, end_line=99
99+
)
100+
]
101+
doc = _doc(text, headings)
102+
cfg = ChunkingConfig(
103+
strategy="docling",
104+
max_tokens=1200,
105+
overlap_tokens=10,
106+
merge_profile="requirements",
107+
)
108+
out = merge_chunks(doc, chunks, cfg)
109+
self.assertEqual(len(out), 2)
110+
111+
def test_narrative_merges_same_heading_crumbs(self) -> None:
112+
# Same heading, tiny crumbs → one merged chunk under narrative profile.
113+
a = "Short intro."
114+
b = "More detail on the same topic."
115+
text = a + "\n\n" + b
116+
mid = len(a) + 2
117+
chunks = [
118+
ChunkInfo(text=a, start_char_idx=0, end_char_idx=len(a)),
119+
ChunkInfo(text=b, start_char_idx=mid, end_char_idx=len(text)),
120+
]
121+
headings = [
122+
HeadingNode(level=2, text="Password Storage", start_line=1, end_line=99)
123+
]
124+
doc = _doc(text, headings)
125+
cfg = ChunkingConfig(
126+
strategy="docling",
127+
max_tokens=1000,
128+
overlap_tokens=10,
129+
merge_profile="narrative",
130+
)
131+
out = merge_chunks(doc, chunks, cfg)
132+
self.assertEqual(len(out), 1)
133+
self.assertIn("Short intro", out[0].text)
134+
self.assertIn("More detail", out[0].text)
135+
136+
def test_narrative_merges_h3_under_same_h2(self) -> None:
137+
text = (
138+
"## Auth\n\n"
139+
"### Intro\n\n"
140+
"First crumb about auth.\n\n"
141+
"### Detail\n\n"
142+
"Second crumb still under Auth."
143+
)
144+
# Approximate char ranges for two leaves under different ### but same ##.
145+
first = "First crumb about auth."
146+
second = "Second crumb still under Auth."
147+
i1 = text.index(first)
148+
i2 = text.index(second)
149+
chunks = [
150+
ChunkInfo(text=first, start_char_idx=i1, end_char_idx=i1 + len(first)),
151+
ChunkInfo(text=second, start_char_idx=i2, end_char_idx=i2 + len(second)),
152+
]
153+
headings = [
154+
HeadingNode(level=2, text="Auth", start_line=1, end_line=20),
155+
HeadingNode(level=3, text="Intro", start_line=3, end_line=6),
156+
HeadingNode(level=3, text="Detail", start_line=8, end_line=20),
157+
]
158+
doc = _doc(text, headings)
159+
cfg = ChunkingConfig(
160+
strategy="docling",
161+
max_tokens=1000,
162+
overlap_tokens=10,
163+
merge_profile="narrative",
164+
)
165+
out = merge_chunks(doc, chunks, cfg)
166+
self.assertEqual(len(out), 1)
167+
168+
def test_profile_defaults_on_config(self) -> None:
169+
req = ChunkingConfig(
170+
strategy="fixed_size",
171+
max_tokens=500,
172+
overlap_tokens=10,
173+
merge_profile="requirements",
174+
)
175+
self.assertTrue(req.split_on_requirement_id)
176+
self.assertEqual(req.merge_min_tokens, 100)
177+
nar = ChunkingConfig(
178+
strategy="fixed_size",
179+
max_tokens=500,
180+
overlap_tokens=10,
181+
merge_profile="narrative",
182+
)
183+
self.assertFalse(nar.split_on_requirement_id)
184+
self.assertEqual(nar.merge_min_tokens, 400)
185+
186+
187+
if __name__ == "__main__":
188+
unittest.main()

‎application/tests/harvester_test/chunk_record_builder_test.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,46 @@ def test_builds_change_record_shaped_payload(self) -> None:
5656
self.assertEqual(record.source_repo, "OWASP/ASVS")
5757
self.assertEqual(record.locator_path, "README.md")
5858
self.assertEqual(record.span.heading_path, ["Root"])
59+
self.assertTrue(record.text.startswith("Standard: ASVS\n"))
60+
self.assertIn("Source: README.md\n", record.text)
61+
self.assertIn("Section: Root\n", record.text)
62+
self.assertIn("First paragraph.", record.text)
5963

6064
ChunkRecordValidator().validate(record)
6165
payload = ingest_record_to_payload(record)
6266
ChangeRecord.model_validate(payload)
6367

68+
def test_prefixes_section_id_from_filename(self) -> None:
69+
text = "# Broken Access Control\n\nDeny by default."
70+
document = Document(
71+
schema_version="0.2.0",
72+
artifact_id="art:B2/owasp_top10_2025:A01",
73+
pipeline_run_id="run-1",
74+
text=text,
75+
source=SourceInfo(
76+
type="github",
77+
repository="B2/owasp_top10_2025",
78+
commit_sha="abc1234",
79+
committed_at=datetime(2026, 2, 1, 1, 0, 0, tzinfo=timezone.utc),
80+
),
81+
locator=Locator(
82+
kind="repo_path",
83+
id="b2/owasp_top10_2025/A01.txt",
84+
path="b2/owasp_top10_2025/A01.txt",
85+
),
86+
heading_structure=[
87+
HeadingNode(
88+
level=1, text="Broken Access Control", start_line=1, end_line=3
89+
)
90+
],
91+
)
92+
chunk = ChunkInfo(text=text, start_char_idx=0, end_char_idx=len(text))
93+
record = ChunkRecordBuilder().build(document, [chunk])[0]
94+
self.assertIn("Standard: owasp_top10_2025", record.text)
95+
self.assertIn("Version: 2025", record.text)
96+
self.assertIn("Section-ID: A01", record.text)
97+
self.assertIn("Section: Broken Access Control", record.text)
98+
6499
def test_indexes_multiple_chunks(self) -> None:
65100
text = "AAAA\n\nBBBB"
66101
document = self._document(text, [])
@@ -74,6 +109,52 @@ def test_indexes_multiple_chunks(self) -> None:
74109
self.assertEqual(records[1].span.index, 1)
75110
self.assertEqual(records[1].chunk_id, "chk:art:OWASP/ASVS:README.md:1")
76111

112+
def test_requirement_extract_chunk_keeps_single_section_id(self) -> None:
113+
"""Do not re-scrape heading/path ids over a clean extractor prefix."""
114+
body = (
115+
"Source: V1.1.2\n"
116+
"Section-ID: V1.1.2\n"
117+
"Section: Verify that output encoding is applied.\n\n"
118+
"Verify that output encoding is applied."
119+
)
120+
document = Document(
121+
schema_version="0.2.0",
122+
artifact_id="art:OWASP/ASVS:0x10-V1.md",
123+
pipeline_run_id="run-1",
124+
text="# V1 Encoding\n\n## V1.1 Architecture\n\n" + body,
125+
source=SourceInfo(
126+
type="github",
127+
repository="OWASP/ASVS",
128+
commit_sha="abc1234deadbeef",
129+
committed_at=datetime(2026, 2, 1, 1, 0, 0, tzinfo=timezone.utc),
130+
),
131+
locator=Locator(
132+
kind="repo_path",
133+
id="0x10-V1.md",
134+
path="5.0/en/0x10-V1-Encoding-and-Sanitization.md",
135+
),
136+
heading_structure=[
137+
HeadingNode(level=1, text="V1 Encoding", start_line=1, end_line=10),
138+
HeadingNode(
139+
level=2, text="V1.1 Architecture", start_line=3, end_line=10
140+
),
141+
],
142+
)
143+
chunk = ChunkInfo(text=body, start_char_idx=0, end_char_idx=len(body))
144+
record = ChunkRecordBuilder().build(document, [chunk])[0]
145+
sid_lines = [
146+
ln for ln in record.text.splitlines() if ln.startswith("Section-ID:")
147+
]
148+
src_lines = [ln for ln in record.text.splitlines() if ln.startswith("Source:")]
149+
# Do not prepend a second Source:/Section-ID: over extractor identity.
150+
self.assertEqual(len(sid_lines), 1)
151+
self.assertEqual(sid_lines[0], "Section-ID: V1.1.2")
152+
self.assertEqual(len(src_lines), 1)
153+
self.assertEqual(src_lines[0], "Source: V1.1.2")
154+
self.assertNotIn("V1.1,", record.text)
155+
self.assertNotIn("5.0,", record.text)
156+
self.assertNotIn("0x10-V1.md", record.text)
157+
77158

78159
if __name__ == "__main__":
79160
unittest.main()

‎application/tests/harvester_test/config_loader_test.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,8 @@ def test_load_packaged_repos_yaml(self):
9999

100100
config = load_repo_config(config_path)
101101

102-
self.assertEqual(len(config.repositories), 2)
103-
self.assertEqual(config.repositories[0].id, "owasp-asvs")
104-
self.assertEqual(config.repositories[1].id, "owasp-cheatsheets")
102+
ids = [repo.id for repo in config.repositories]
103+
self.assertEqual(ids, ["owasp-asvs", "owasp-aisvs", "owasp-cheatsheets"])
105104

106105
def test_empty_owner(self):
107106
config_path = FIXTURES_DIR / "empty_owner.yaml"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Unit tests for point-and-shoot GitHub URL ingest helpers."""
2+
3+
from __future__ import annotations
4+
5+
import unittest
6+
7+
from application.utils.harvester.github_url_ingest import (
8+
format_knowledge_item,
9+
parse_github_url,
10+
)
11+
12+
13+
class ParseGitHubUrlTests(unittest.TestCase):
14+
def test_owner_repo(self) -> None:
15+
p = parse_github_url("https://github.com/OWASP/ASVS")
16+
self.assertEqual(p.owner, "OWASP")
17+
self.assertEqual(p.repo, "ASVS")
18+
self.assertIsNone(p.branch)
19+
self.assertIsNone(p.path_prefix)
20+
21+
def test_git_suffix_and_www(self) -> None:
22+
p = parse_github_url("https://www.github.com/OWASP/ASVS.git")
23+
self.assertEqual(p.owner, "OWASP")
24+
self.assertEqual(p.repo, "ASVS")
25+
26+
def test_tree_branch_and_path(self) -> None:
27+
p = parse_github_url("https://github.com/OWASP/ASVS/tree/master/5.0/en")
28+
self.assertEqual(p.branch, "master")
29+
self.assertEqual(p.path_prefix, "5.0/en")
30+
31+
def test_rejects_non_github(self) -> None:
32+
with self.assertRaises(ValueError):
33+
parse_github_url("https://gitlab.com/OWASP/ASVS")
34+
35+
36+
class FormatKnowledgeItemTests(unittest.TestCase):
37+
def test_includes_path_and_label(self) -> None:
38+
out = format_knowledge_item(
39+
path="5.0/en/V1.md",
40+
label="KNOWLEDGE",
41+
confidence=0.91,
42+
text="Verify that input is sanitized.",
43+
reasoning="looks like a control",
44+
)
45+
self.assertIn("path: 5.0/en/V1.md", out)
46+
self.assertIn("label: KNOWLEDGE", out)
47+
self.assertIn("Verify that input is sanitized.", out)
48+
49+
50+
if __name__ == "__main__":
51+
unittest.main()

0 commit comments

Comments
 (0)