From 8fe43e1406915e68751706324e4117214f9c7053 Mon Sep 17 00:00:00 2001 From: dalbee Date: Wed, 13 May 2026 10:04:47 +0900 Subject: [PATCH 1/4] Improve package discovery for unavailable installs Add a no-dependency APT Packages index search helper so CX Linux can show useful fuzzy, synonym-aware package matches before broader AI-backed install flows exist. The PR keeps the implementation scoped to repository metadata and fixture tests because the distro build scripts require privileged host operations and are not safe to run during this change. Constraint: Existing repository install/build scripts include privileged and destructive host operations, so verification is limited to static Python and fixture shell tests. Rejected: Implement full LLM-backed cortex search in this repo | the issue spans cx-core behavior and would be too broad for a small distro-side PR. Confidence: medium Scope-risk: narrow Directive: Keep this helper read-only against Packages indexes; do not make it install or execute package contents. Tested: python3 -m py_compile apt/scripts/search-packages.py Tested: bash -n tests/search-packages-test.sh Tested: make test-search Tested: git diff --check Not-tested: Full distro build or package installation due privileged host-side scripts flagged by repo vet. --- Makefile | 7 +- apt/README.md | 30 +++++ apt/scripts/search-packages.py | 232 +++++++++++++++++++++++++++++++++ tests/search-packages-test.sh | 70 ++++++++++ 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100755 apt/scripts/search-packages.py create mode 100755 tests/search-packages-test.sh diff --git a/Makefile b/Makefile index b5862e3..ecd1600 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 SHELL := /bin/bash -.PHONY: all iso iso-netinst iso-offline package sbom clean test help +.PHONY: all iso iso-netinst iso-offline package sbom clean test test-search help # Build configuration CODENAME := trixie @@ -37,6 +37,7 @@ help: @echo " package PKG=x Build specific package (cx-core, cx-full, cx-archive-keyring)" @echo " sbom Generate Software Bill of Materials" @echo " test Run build verification tests" + @echo " test-search Run package search helper tests" @echo " clean Remove build artifacts" @echo " deps Install build dependencies" @echo "" @@ -162,6 +163,10 @@ test: ./tests/verify-preseed.sh || true @echo -e "$(GREEN)Tests complete$(NC)" +test-search: + @echo -e "$(GREEN)Running package search tests...$(NC)" + ./tests/search-packages-test.sh + # Clean build artifacts clean: @echo -e "$(YELLOW)Cleaning build artifacts...$(NC)" diff --git a/apt/README.md b/apt/README.md index 3a54e71..c1d3403 100644 --- a/apt/README.md +++ b/apt/README.md @@ -81,6 +81,36 @@ git commit -m "Add mypackage 1.0.0" git push ``` +## Smart Package Search + +Use `apt/scripts/search-packages.py` to query generated `Packages` indexes before +publishing or while debugging the repository locally. It supports exact package +names, short natural-language queries, common synonyms, and small typos without +requiring network access or extra Python dependencies. + +```bash +# Search generated repository indexes under dists/ +./apt/scripts/search-packages.py "web server" + +# Search a specific Packages file +./apt/scripts/search-packages.py --index dists/stable/main/binary-amd64/Packages postgress +``` + +Example output: + +```text +Results: + 1. postgresql (16+257) + object-relational SQL database + score=33 reason=fuzzy name; fuzzy term +``` + +Run the focused test suite with: + +```bash +make test-search +``` + ### Method 2: Workflow dispatch Go to Actions → Publish APT Repository → Run workflow diff --git a/apt/scripts/search-packages.py b/apt/scripts/search-packages.py new file mode 100755 index 0000000..170b63c --- /dev/null +++ b/apt/scripts/search-packages.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Search CX APT package indexes with fuzzy and synonym-aware ranking.""" + +from __future__ import annotations + +import argparse +import gzip +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +SYNONYMS = { + "database": {"database", "db", "sql", "postgres", "postgresql", "mysql"}, + "gpu": {"gpu", "nvidia", "amd", "graphics", "cuda", "rocm"}, + "monitoring": {"monitoring", "metrics", "observability", "prometheus"}, + "security": {"security", "hardening", "firewall", "sandbox", "secops"}, + "web": {"web", "http", "server", "nginx", "apache", "caddy"}, +} + + +@dataclass(frozen=True) +class Package: + name: str + version: str + description: str + fields: dict[str, str] + + +@dataclass(frozen=True) +class SearchResult: + package: Package + score: int + reason: str + + +def normalize(value: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip() + + +def tokens(value: str) -> set[str]: + return {token for token in normalize(value).split() if token} + + +def levenshtein(a: str, b: str) -> int: + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + + previous = list(range(len(b) + 1)) + for i, char_a in enumerate(a, 1): + current = [i] + for j, char_b in enumerate(b, 1): + current.append( + min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + (char_a != char_b), + ) + ) + previous = current + return previous[-1] + + +def expanded_query_terms(query: str) -> set[str]: + query_terms = tokens(query) + expanded = set(query_terms) + for term in query_terms: + for group in SYNONYMS.values(): + if term in group: + expanded.update(group) + return expanded + + +def open_index(path: Path): + if path.suffix == ".gz": + return gzip.open(path, "rt", encoding="utf-8", errors="replace") + return path.open("r", encoding="utf-8", errors="replace") + + +def parse_packages_index(path: Path) -> list[Package]: + packages: list[Package] = [] + current: dict[str, str] = {} + last_key: str | None = None + + with open_index(path) as handle: + for raw_line in handle: + line = raw_line.rstrip("\n") + if not line: + if current: + packages.append(package_from_fields(current)) + current = {} + last_key = None + continue + + if line.startswith(" ") and last_key: + current[last_key] = f"{current[last_key]}\n{line.strip()}" + continue + + key, separator, value = line.partition(":") + if separator: + last_key = key + current[key] = value.strip() + + if current: + packages.append(package_from_fields(current)) + + return packages + + +def package_from_fields(fields: dict[str, str]) -> Package: + return Package( + name=fields.get("Package", ""), + version=fields.get("Version", ""), + description=fields.get("Description", ""), + fields=dict(fields), + ) + + +def score_package(package: Package, query: str) -> SearchResult | None: + query_norm = normalize(query) + query_terms = expanded_query_terms(query) + name_norm = normalize(package.name) + package_terms = tokens(f"{package.name} {package.description}") + + score = 0 + reasons: list[str] = [] + + if query_norm == name_norm: + score += 100 + reasons.append("exact name") + elif query_norm in name_norm: + score += 75 + reasons.append("name contains query") + + overlap = query_terms & package_terms + if overlap: + score += 12 * len(overlap) + reasons.append("matched " + ", ".join(sorted(overlap)[:4])) + + closest_distance = min( + (levenshtein(term, package.name) for term in tokens(query)), + default=99, + ) + if closest_distance <= 2: + score += 45 - (closest_distance * 10) + reasons.append("fuzzy name") + + for query_term in tokens(query): + if len(query_term) < 4: + continue + for package_term in package_terms: + if len(package_term) < 4: + continue + similar_length = abs(len(query_term) - len(package_term)) <= 2 + similar_spelling = levenshtein(query_term, package_term) <= 2 + if similar_length and similar_spelling: + score += 8 + reasons.append("fuzzy term") + break + + if score <= 0: + return None + + return SearchResult(package=package, score=score, reason="; ".join(dict.fromkeys(reasons))) + + +def search(packages: list[Package], query: str, limit: int) -> list[SearchResult]: + results = [result for package in packages if (result := score_package(package, query))] + return sorted(results, key=lambda result: (-result.score, result.package.name))[:limit] + + +def default_index_paths(repo_root: Path) -> list[Path]: + dists = repo_root / "dists" + if not dists.exists(): + return [] + return sorted(dists.glob("**/Packages")) + sorted(dists.glob("**/Packages.gz")) + + +def load_packages(repo_root: Path, indexes: list[Path]) -> list[Package]: + paths = indexes or default_index_paths(repo_root) + packages: list[Package] = [] + for path in paths: + if not path.exists(): + raise FileNotFoundError(f"package index not found: {path}") + packages.extend(parse_packages_index(path)) + return packages + + +def print_results(results: list[SearchResult]) -> None: + if not results: + print("No matching packages found.") + return + + print("Results:") + for index, result in enumerate(results, 1): + package = result.package + print(f" {index}. {package.name} ({package.version or 'unknown version'})") + if package.description: + first_line = package.description.splitlines()[0] + print(f" {first_line}") + print(f" score={result.score} reason={result.reason}") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("query", help="Package name, typo, synonym, or natural-language query") + parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="APT repository root") + parser.add_argument("--index", type=Path, action="append", default=[], help="Packages or Packages.gz file") + parser.add_argument("--limit", type=int, default=5, help="Maximum results to show") + args = parser.parse_args(argv) + + try: + packages = load_packages(args.repo_root, args.index) + except OSError as error: + print(error, file=sys.stderr) + return 2 + + if not packages: + print("No package indexes found. Pass --index or run from an APT repository root.", file=sys.stderr) + return 2 + + print_results(search(packages, args.query, args.limit)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/search-packages-test.sh b/tests/search-packages-test.sh new file mode 100755 index 0000000..af5d0fb --- /dev/null +++ b/tests/search-packages-test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +INDEX="$TMP_DIR/Packages" + +cat > "$INDEX" <<'EOF' +Package: nginx +Version: 1.24.0-1 +Description: small, powerful, scalable web server + +Package: apache2 +Version: 2.4.58-1 +Description: Apache HTTP server + +Package: postgresql +Version: 16+257 +Description: object-relational SQL database + +Package: cx-secops +Version: 0.1.0-1 +Description: CX Linux security hardening and sandbox tools + +Package: cx-gpu-nvidia +Version: 0.1.0-1 +Description: NVIDIA GPU runtime helpers for CX Linux +EOF + +run_search() { + "$ROOT_DIR/apt/scripts/search-packages.py" --index "$INDEX" "$@" +} + +assert_contains() { + local haystack="$1" + local needle="$2" + + if [[ "$haystack" != *"$needle"* ]]; then + echo "Expected output to contain: $needle" >&2 + echo "$haystack" >&2 + exit 1 + fi +} + +output="$(run_search postgress)" +assert_contains "$output" "postgresql" +assert_contains "$output" "fuzzy" + +output="$(run_search "web server")" +assert_contains "$output" "nginx" +assert_contains "$output" "apache2" + +output="$(run_search "graphics card")" +assert_contains "$output" "cx-gpu-nvidia" + +output="$(run_search hardening)" +assert_contains "$output" "cx-secops" + +output="$(run_search "not-a-real-package")" +assert_contains "$output" "No matching packages found." + +if "$ROOT_DIR/apt/scripts/search-packages.py" --index "$TMP_DIR/missing" postgresql 2> "$TMP_DIR/missing.err"; then + echo "Expected missing index to fail" >&2 + exit 1 +fi +assert_contains "$(cat "$TMP_DIR/missing.err")" "package index not found" + +echo "search-packages-test.sh: all assertions passed" From a5cd09f8e111165ab6058615cb2d88c874be2722 Mon Sep 17 00:00:00 2001 From: dalbee Date: Wed, 13 May 2026 10:07:42 +0900 Subject: [PATCH 2/4] Tighten package search index handling Fix the default repository discovery path so plain Packages files win over matching Packages.gz files, preventing duplicate results in normal APT repositories. Also let typoed synonym queries resolve through a bounded fuzzy alias pass while keeping the guard narrow enough to avoid unrelated alias expansion. Constraint: Follow-up review found the documented default path could duplicate package rows when both index encodings exist. Rejected: Parse both encodings and dedupe by package name | would hide real duplicate package stanzas across suites or architectures. Confidence: high Scope-risk: narrow Tested: python3 -m py_compile apt/scripts/search-packages.py Tested: bash -n tests/search-packages-test.sh Tested: make test-search Tested: git diff --check --- apt/scripts/search-packages.py | 22 ++++++++++++++++++++-- tests/search-packages-test.sh | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apt/scripts/search-packages.py b/apt/scripts/search-packages.py index 170b63c..dade1d5 100755 --- a/apt/scripts/search-packages.py +++ b/apt/scripts/search-packages.py @@ -19,6 +19,8 @@ "web": {"web", "http", "server", "nginx", "apache", "caddy"}, } +SYNONYM_ALIASES = {alias for aliases in SYNONYMS.values() for alias in aliases} + @dataclass(frozen=True) class Package: @@ -70,8 +72,18 @@ def expanded_query_terms(query: str) -> set[str]: query_terms = tokens(query) expanded = set(query_terms) for term in query_terms: + closest_alias = min(SYNONYM_ALIASES, key=lambda alias: levenshtein(term, alias), default="") + fuzzy_alias = ( + closest_alias + and len(term) >= 4 + and term[0] == closest_alias[0] + and levenshtein(term, closest_alias) <= 2 + ) + if fuzzy_alias: + expanded.add(closest_alias) + for group in SYNONYMS.values(): - if term in group: + if term in group or (fuzzy_alias and closest_alias in group): expanded.update(group) return expanded @@ -178,7 +190,13 @@ def default_index_paths(repo_root: Path) -> list[Path]: dists = repo_root / "dists" if not dists.exists(): return [] - return sorted(dists.glob("**/Packages")) + sorted(dists.glob("**/Packages.gz")) + + plain_indexes = sorted(dists.glob("**/Packages")) + plain_dirs = {path.parent for path in plain_indexes} + gzip_only_indexes = sorted( + path for path in dists.glob("**/Packages.gz") if path.parent not in plain_dirs + ) + return plain_indexes + gzip_only_indexes def load_packages(repo_root: Path, indexes: list[Path]) -> list[Package]: diff --git a/tests/search-packages-test.sh b/tests/search-packages-test.sh index af5d0fb..3312f4a 100755 --- a/tests/search-packages-test.sh +++ b/tests/search-packages-test.sh @@ -28,11 +28,20 @@ Package: cx-gpu-nvidia Version: 0.1.0-1 Description: NVIDIA GPU runtime helpers for CX Linux EOF +gzip -c "$INDEX" > "$INDEX.gz" + +mkdir -p "$TMP_DIR/repo/dists/stable/main/binary-amd64" +cp "$INDEX" "$TMP_DIR/repo/dists/stable/main/binary-amd64/Packages" +cp "$INDEX.gz" "$TMP_DIR/repo/dists/stable/main/binary-amd64/Packages.gz" run_search() { "$ROOT_DIR/apt/scripts/search-packages.py" --index "$INDEX" "$@" } +run_repo_search() { + "$ROOT_DIR/apt/scripts/search-packages.py" --repo-root "$TMP_DIR/repo" "$@" +} + assert_contains() { local haystack="$1" local needle="$2" @@ -55,9 +64,23 @@ assert_contains "$output" "apache2" output="$(run_search "graphics card")" assert_contains "$output" "cx-gpu-nvidia" +output="$(run_search "grafics card")" +assert_contains "$output" "cx-gpu-nvidia" + output="$(run_search hardening)" assert_contains "$output" "cx-secops" +output="$("$ROOT_DIR/apt/scripts/search-packages.py" --index "$INDEX.gz" "web server")" +assert_contains "$output" "nginx" + +output="$(run_repo_search postgresql)" +postgres_count="$(grep -c "^ [0-9][.] postgresql " <<< "$output")" +if [[ "$postgres_count" -ne 1 ]]; then + echo "Expected repo-root search to dedupe Packages and Packages.gz" >&2 + echo "$output" >&2 + exit 1 +fi + output="$(run_search "not-a-real-package")" assert_contains "$output" "No matching packages found." From 21c5408c03b7e7be7a0f91aa96677ebf3a058608 Mon Sep 17 00:00:00 2001 From: dalbee Date: Wed, 13 May 2026 10:19:27 +0900 Subject: [PATCH 3/4] Avoid repeated package search query work Review feedback called out redundant scoring work in the smart package search loop. The search path now builds normalized query state once and caches edit-distance calculations while keeping the ranking behavior covered by the existing fixture tests. Constraint: Keep the helper dependency-free and preserve the current CLI output Rejected: Add a heavier search index abstraction | unnecessary for the current APT Packages helper Confidence: high Scope-risk: narrow Tested: python3 -m py_compile apt/scripts/search-packages.py Tested: bash -n tests/search-packages-test.sh Tested: make test-search Tested: git diff --check --- apt/scripts/search-packages.py | 38 ++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/apt/scripts/search-packages.py b/apt/scripts/search-packages.py index dade1d5..59e3231 100755 --- a/apt/scripts/search-packages.py +++ b/apt/scripts/search-packages.py @@ -8,6 +8,7 @@ import re import sys from dataclasses import dataclass +from functools import lru_cache from pathlib import Path @@ -37,6 +38,13 @@ class SearchResult: reason: str +@dataclass(frozen=True) +class QueryContext: + normalized: str + tokens: set[str] + expanded_terms: set[str] + + def normalize(value: str) -> str: return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip() @@ -45,6 +53,7 @@ def tokens(value: str) -> set[str]: return {token for token in normalize(value).split() if token} +@lru_cache(maxsize=4096) def levenshtein(a: str, b: str) -> int: if a == b: return 0 @@ -68,8 +77,7 @@ def levenshtein(a: str, b: str) -> int: return previous[-1] -def expanded_query_terms(query: str) -> set[str]: - query_terms = tokens(query) +def expanded_query_terms(query_terms: set[str]) -> set[str]: expanded = set(query_terms) for term in query_terms: closest_alias = min(SYNONYM_ALIASES, key=lambda alias: levenshtein(term, alias), default="") @@ -88,6 +96,15 @@ def expanded_query_terms(query: str) -> set[str]: return expanded +def build_query_context(query: str) -> QueryContext: + query_tokens = tokens(query) + return QueryContext( + normalized=normalize(query), + tokens=query_tokens, + expanded_terms=expanded_query_terms(query_tokens), + ) + + def open_index(path: Path): if path.suffix == ".gz": return gzip.open(path, "rt", encoding="utf-8", errors="replace") @@ -133,36 +150,34 @@ def package_from_fields(fields: dict[str, str]) -> Package: ) -def score_package(package: Package, query: str) -> SearchResult | None: - query_norm = normalize(query) - query_terms = expanded_query_terms(query) +def score_package(package: Package, query: QueryContext) -> SearchResult | None: name_norm = normalize(package.name) package_terms = tokens(f"{package.name} {package.description}") score = 0 reasons: list[str] = [] - if query_norm == name_norm: + if query.normalized == name_norm: score += 100 reasons.append("exact name") - elif query_norm in name_norm: + elif query.normalized in name_norm: score += 75 reasons.append("name contains query") - overlap = query_terms & package_terms + overlap = query.expanded_terms & package_terms if overlap: score += 12 * len(overlap) reasons.append("matched " + ", ".join(sorted(overlap)[:4])) closest_distance = min( - (levenshtein(term, package.name) for term in tokens(query)), + (levenshtein(term, package.name) for term in query.tokens), default=99, ) if closest_distance <= 2: score += 45 - (closest_distance * 10) reasons.append("fuzzy name") - for query_term in tokens(query): + for query_term in query.tokens: if len(query_term) < 4: continue for package_term in package_terms: @@ -182,7 +197,8 @@ def score_package(package: Package, query: str) -> SearchResult | None: def search(packages: list[Package], query: str, limit: int) -> list[SearchResult]: - results = [result for package in packages if (result := score_package(package, query))] + query_context = build_query_context(query) + results = [result for package in packages if (result := score_package(package, query_context))] return sorted(results, key=lambda result: (-result.score, result.package.name))[:limit] From 17a422e83eb3926609f8b93c2821e27d06810cfc Mon Sep 17 00:00:00 2001 From: dalbee Date: Wed, 13 May 2026 10:21:41 +0900 Subject: [PATCH 4/4] Reject empty queries and dedupe search results Subagent review found two correctness gaps in the package search helper: punctuation-only queries normalized to an empty string and matched every package, and overlapping indexes could return duplicate package rows. The search path now returns no results for empty normalized queries and deduplicates both index paths and parsed package identities. Constraint: Preserve the existing no-dependency CLI and current ranking behavior for valid queries Rejected: Add persistent package index state | unnecessary for a single-command helper Confidence: high Scope-risk: narrow Tested: python3 -m py_compile apt/scripts/search-packages.py Tested: bash -n tests/search-packages-test.sh Tested: make test-search Tested: git diff --check --- apt/scripts/search-packages.py | 23 +++++++++++++++++++---- tests/search-packages-test.sh | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apt/scripts/search-packages.py b/apt/scripts/search-packages.py index 59e3231..1341728 100755 --- a/apt/scripts/search-packages.py +++ b/apt/scripts/search-packages.py @@ -183,9 +183,9 @@ def score_package(package: Package, query: QueryContext) -> SearchResult | None: for package_term in package_terms: if len(package_term) < 4: continue - similar_length = abs(len(query_term) - len(package_term)) <= 2 - similar_spelling = levenshtein(query_term, package_term) <= 2 - if similar_length and similar_spelling: + if abs(len(query_term) - len(package_term)) > 2: + continue + if levenshtein(query_term, package_term) <= 2: score += 8 reasons.append("fuzzy term") break @@ -198,6 +198,9 @@ def score_package(package: Package, query: QueryContext) -> SearchResult | None: def search(packages: list[Package], query: str, limit: int) -> list[SearchResult]: query_context = build_query_context(query) + if not query_context.normalized: + return [] + results = [result for package in packages if (result := score_package(package, query_context))] return sorted(results, key=lambda result: (-result.score, result.package.name))[:limit] @@ -218,10 +221,22 @@ def default_index_paths(repo_root: Path) -> list[Path]: def load_packages(repo_root: Path, indexes: list[Path]) -> list[Package]: paths = indexes or default_index_paths(repo_root) packages: list[Package] = [] + seen_paths: set[Path] = set() + seen_packages: set[tuple[str, str, str]] = set() for path in paths: + path_key = path.resolve() + if path_key in seen_paths: + continue + seen_paths.add(path_key) + if not path.exists(): raise FileNotFoundError(f"package index not found: {path}") - packages.extend(parse_packages_index(path)) + for package in parse_packages_index(path): + package_key = (package.name, package.version, package.description) + if package_key in seen_packages: + continue + seen_packages.add(package_key) + packages.append(package) return packages diff --git a/tests/search-packages-test.sh b/tests/search-packages-test.sh index 3312f4a..28cfa09 100755 --- a/tests/search-packages-test.sh +++ b/tests/search-packages-test.sh @@ -6,6 +6,7 @@ TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT INDEX="$TMP_DIR/Packages" +DUP_INDEX="$TMP_DIR/Packages.duplicate" cat > "$INDEX" <<'EOF' Package: nginx @@ -29,6 +30,7 @@ Version: 0.1.0-1 Description: NVIDIA GPU runtime helpers for CX Linux EOF gzip -c "$INDEX" > "$INDEX.gz" +cp "$INDEX" "$DUP_INDEX" mkdir -p "$TMP_DIR/repo/dists/stable/main/binary-amd64" cp "$INDEX" "$TMP_DIR/repo/dists/stable/main/binary-amd64/Packages" @@ -84,6 +86,25 @@ fi output="$(run_search "not-a-real-package")" assert_contains "$output" "No matching packages found." +output="$(run_search "!!!")" +assert_contains "$output" "No matching packages found." + +output="$("$ROOT_DIR/apt/scripts/search-packages.py" --index "$INDEX" --index "$INDEX" postgresql)" +postgres_count="$(grep -c "^ [0-9][.] postgresql " <<< "$output")" +if [[ "$postgres_count" -ne 1 ]]; then + echo "Expected duplicate --index paths to be deduped" >&2 + echo "$output" >&2 + exit 1 +fi + +output="$("$ROOT_DIR/apt/scripts/search-packages.py" --index "$INDEX" --index "$DUP_INDEX" postgresql)" +postgres_count="$(grep -c "^ [0-9][.] postgresql " <<< "$output")" +if [[ "$postgres_count" -ne 1 ]]; then + echo "Expected overlapping package indexes to be deduped" >&2 + echo "$output" >&2 + exit 1 +fi + if "$ROOT_DIR/apt/scripts/search-packages.py" --index "$TMP_DIR/missing" postgresql 2> "$TMP_DIR/missing.err"; then echo "Expected missing index to fail" >&2 exit 1