From e401671a68ac64644d5ca0f3d3fceb4574886989 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 16 Sep 2026 07:01:01 -0400 Subject: [PATCH 1/2] feat(project): add deterministic PR search and memory tags --- .github/workflows/ci.yml | 24 +- README.md | 18 + docs/project-intelligence.md | 266 +++++++++++++++ docs/status.md | 1 + nano/__init__.py | 1 + nano/cli/main.py | 17 + nano/project/__init__.py | 13 + nano/project/catalog.py | 125 +++++++ nano/project/cli.py | 40 +++ nano/project/fixtures/queries.json | 33 ++ nano/project/fixtures/record.json | 23 ++ nano/project/fixtures/records.json | 76 +++++ nano/project/index.py | 142 ++++++++ nano/project/query.py | 256 ++++++++++++++ nano/project/records.py | 249 ++++++++++++++ .../schemas/project-query-1.0.0.schema.json | 313 ++++++++++++++++++ pyproject.toml | 2 + tests/test_project.py | 308 +++++++++++++++++ 18 files changed, 1901 insertions(+), 6 deletions(-) create mode 100644 docs/project-intelligence.md create mode 100644 nano/project/__init__.py create mode 100644 nano/project/catalog.py create mode 100644 nano/project/cli.py create mode 100644 nano/project/fixtures/queries.json create mode 100644 nano/project/fixtures/record.json create mode 100644 nano/project/fixtures/records.json create mode 100644 nano/project/index.py create mode 100644 nano/project/query.py create mode 100644 nano/project/records.py create mode 100644 nano/project/schemas/project-query-1.0.0.schema.json create mode 100644 tests/test_project.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f357338..cbb9cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: python-version: "3.10" - name: Build wheel run: python -m pip wheel --no-deps --wheel-dir dist . - - name: Verify packaged strategy assets + - name: Verify packaged strategy and project assets run: | python - <<'PY' from pathlib import Path @@ -61,7 +61,7 @@ jobs: wheel = next(Path("dist").glob("*.whl")) expected = { path.as_posix() - for root in (Path("nano/examples"), Path("nano/library")) + for root in (Path("nano/examples"), Path("nano/library"), Path("nano/project")) for path in root.rglob("*") if path.suffix in {".nano", ".json"} } @@ -69,19 +69,25 @@ jobs: packaged = set(archive.namelist()) missing = sorted(expected - packaged) - assert not missing, f"wheel is missing documented strategy assets: {missing}" + assert not missing, f"wheel is missing documented assets: {missing}" required_modules = { "nano/library/__init__.py", "nano/library/catalog.py", "nano/library/contribution.py", + "nano/project/__init__.py", + "nano/project/catalog.py", + "nano/project/records.py", + "nano/project/query.py", + "nano/project/index.py", + "nano/project/cli.py", } missing_modules = sorted(required_modules - packaged) assert not missing_modules, ( - f"wheel is missing library validation modules: {missing_modules}" + f"wheel is missing required modules: {missing_modules}" ) PY - - name: Verify installed library validation imports + - name: Verify installed library and project discovery run: | python - <<'PY' import subprocess @@ -108,7 +114,13 @@ jobs: "'nano.library.contribution'; " "catalog = load_catalog(); " "assert catalog['strategyCount'] == 55; " - "assert catalog_diagnostics() == ()" + "assert catalog_diagnostics() == (); " + "import json; from importlib.resources import files; " + "from nano.project import ProjectIndex; " + "data = files('nano.project').joinpath('fixtures/records.json').read_text(); " + "result = ProjectIndex(json.loads(data)).search(" + "'frontend css pending review', project_id='example-project'); " + "assert [x['recordId'] for x in result['items']] == ['pr:9001:41']" ), ], cwd=outside_checkout, diff --git a/README.md b/README.md index e776678..69f4314 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,24 @@ IR `0.1.0`, catalogs, effects, consent policy, and canonical fixtures. ## Quick start +### Project search and compact memory + +Nano also supports general application discovery through `nano.project`: +classify PRs, commits, and memory records into evidence-bearing tags; compile +searches such as `frontend css pending review`; group results; suggest titles; +and emit compact agent summaries. It uses no model or network calls. + +```bash +nano project parse "frontend css pending review group by area" +nano project search nano/project/fixtures/records.json "css" --project-id example-project +``` + +The host supplies authorized records and current status observations. Nano +does not rename PRs or upload memory. See the [project intelligence contract +and APR integration guide](docs/project-intelligence.md). + +### Strategy quick start + From a fresh checkout, run the bundled Momentum strategy and then the test suite: ```bash diff --git a/docs/project-intelligence.md b/docs/project-intelligence.md new file mode 100644 index 0000000..d56e88e --- /dev/null +++ b/docs/project-intelligence.md @@ -0,0 +1,266 @@ +# Project search, PR tags, and compact memory + +Nano now ships a small, model-free project discovery layer in `nano.project`. +It classifies supplied PR/commit/memory metadata, compiles search phrases, +groups matching records, suggests conventional titles, and emits compact agent +capsules. It does not fetch GitHub, rename PRs, write memory, or execute actions. + +This is the Nano-side implementation for [proposal #41](https://github.com/AetherAI3/Nano/issues/41). +Online search, GitHub ingestion, and APR annotation persistence are integration +work in Aether Cloud/Agent. No UI deployment is implied by this package. + +## The user experience + +In an APR project, one search field accepts: + +- frontend css pending review +- open PRs with failed CI +- frontend not merged +- [feature][frontend][css][pending review] +- css or memory graph +- "dropdown animation" +- author:brandon path:site/ after:2026-09-01 +- css group by review + +Show the parsed filters as removable chips. Show free text separately, preserve +the original query, and render diagnostics next to the offending span. +Offer "Group by area / type / review / CI" as ordinary controls; users need not +learn syntax. Do not execute an older valid query's results under newer invalid +text. Cancel or sequence network requests so stale responses cannot replace a +newer query. + +A PR card can read: + +> [feature] [frontend] [css] [downstream] [pending review] + +Its compact agent capsule also includes the record identity, bounded title, +lifecycle state, review and CI observations, abbreviated source SHA, and +observation time. The JSON retains the full SHA and tag evidence. Agents can +discover candidates cheaply and then fetch the exact PR, diff, checks, or +memory revision before doing work. Tags are retrieval hints, not proof that +code works or that a PR is merge-ready. + +Keep the existing APR project cards. Add a small search/count entry point in +the project view, useful saved filters, and grouped PR rows. In the graph, use +the same facets to filter or highlight existing nodes and explain connections. +Selecting a chip should mean the same thing in the PR list and graph. + +## Python and CLI + +The checked-in fixture is synthetic example data, not live Aether work. + +```python +from nano.project import ProjectIndex, classify_record, compile_query + +record = { + "id": "pr:9001:41", "project_id": "example-project", "kind": "pr", + "repository": "example/project", "number": 41, + "title": "feat(frontend): add CSS search filters", + "files": ["site/components/search.css"], + "source_sha": "a" * 40, + "state": "open", "review": "pending", "ci": "passed", + "ci_sha": "a" * 40, "review_sha": "a" * 40, + "observed_at": "2026-09-16T10:00:00Z", + "updated_at": "2026-09-16T09:00:00Z", + "depends_on": ["pr:9001:40"], +} +annotation = classify_record(record) +query = compile_query("frontend css pending review group by area") +result = ProjectIndex([record]).search(query, project_id="example-project") +assert result["total"] == 1 +print(annotation["capsule"]) +``` + +```bash +nano project parse "frontend css pending review" +nano project classify nano/project/fixtures/record.json +nano project search nano/project/fixtures/records.json "css group by review" --project-id example-project +``` + +All commands emit JSON. Parse diagnostics return exit 1; input read failures +return 3. The embedding API is pure; the CLI alone reads the supplied JSON file. +There are no mandatory dependencies beyond the Python standard library. + +## Record and annotation contract + +Required input: id, project_id, kind (pr/commit/memory), and title. +Optional fields: repository, number, author, body, files, labels, source_sha, +state, review, ci, ci_sha, review_sha, observed_at, updated_at, depends_on, +blocks, tag_overrides, files_complete. Unknown fields are rejected. +Identity fields (id, project_id, repository, author) cannot contain whitespace. + +Use host-owned stable record IDs, such as a provider repository numeric ID plus +PR number. repository is a display/search projection, not an authorization +identity. A commit's source_sha is a full 40- or 64-character lowercase Git +object ID. Resolve abbreviations through the host before recording them. + +| Facet | How it is derived | +| --- | --- | +| type | Conventional title, recognized labels, or explicit bracket tags | +| area | Recognized labels, title vocabulary, and known path components | +| tech | Recognized labels/title words and changed-file suffixes | +| relation | downstream only from supplied depends_on; upstream only from blocks | +| state | Supplied host lifecycle observation | +| review | Supplied host observation bound by review_sha to source_sha | +| ci | Supplied host observation bound by ci_sha to source_sha | + +A guessed path tag is explicitly derived evidence. It must not become a +canonical claim about architecture, behavior, test success, or dependency +ordering. Body text is searchable but does not generate operational status. +A label such as "approved" or title saying "CI passed" never establishes that +status. Missing/mismatched head binding makes review/CI unknown. This also +applies to "none": no checks observed is different from checks not read. + +Nano records observed_at but reads no clock. The host owns TTL and refresh: +before presenting a snapshot as current, check its age, provider authority, +and current source SHA. Status without an observation time is diagnosed. +An old approved snapshot is historical evidence, not current approval. + +files_complete defaults to false. A tag's absence means it was not indexed, +not that no file of that kind exists. Show partial coverage where the host has +only a bounded changed-file list. + +Overrides replace inferred type/area/tech values, including an empty list to +remove them. They cannot manufacture status or dependency facts: + +```json +{"tag_overrides":{"area":["backend"],"tech":[]}} +``` + +Each output includes nanoProjectVersion 1.0.0, vocabularyHash, annotationHash, +record/project identities, full source SHA, original and suggested titles, +evidence-bearing tags, status, explicit dependencies, diagnostics, and capsule. +annotationHash identifies this derived output; it is not a signature or proof +of authenticated input. Naming is a suggestion only; Nano never writes GitHub. + +## Search contract + +nanoProjectQueryVersion 1.0.0 is independent of strategy and market Intent IR. +The packaged schema is nano/project/schemas/project-query-1.0.0.schema.json. +It describes compiler output for other clients. Compile source text with +compile_query on the server; do not trust a browser's valid flag or query hash +as validation or authorization. Python validation also enforces aggregate +predicate limits and nonempty OR branches across the complete query. + +A query is anyOf OR clauses, each containing allOf predicates. +Whitespace and "and" combine predicates with AND. "or" separates clauses. +"not", "without", "exclude", and a leading minus negate the following predicate. +Repeated fields are AND-ed. Use explicit OR clauses for alternatives. + +Fields: type, area, tech, relation, state, review, ci, kind, repo, author, label, +tag, number, sha, path, text, after, before. +Short aliases: technology -> tech, repository -> repo, status -> state. +#41 is number:41. Bracketed chips accept known phrases or a literal tag value. +Explicit label filters match labels exactly; tag matches any semantic tag value. + +Quoted text is literal, including words such as "not" or "pending review". +Unknown bare words remain full-text predicates. Only the documented filler +words show/find/search/me/the/with/that/are/all are skipped outside quotes or +bracketed chips. Quote those words to search for them literally. +A term such as "pending" alone is text; "pending review" is a review filter. +Unknown named filters, malformed quotes, invalid dates/statuses, dangling +negation/operators, and unsupported parentheses produce diagnostics. +Invalid queries cannot run. Current bounds: 4,096 characters, 256 tokens, +64 predicates, and 8 OR clauses. + +Unquoted top-level group by FIELD selects one grouping dimension; sort by +updated/number/title selects deterministic order. Date filters compare UTC +calendar dates: after is inclusive, before exclusive. Relative dates are not +interpreted by v1; the UI can supply explicit dates. Paths match a prefix, or +a glob if * or ? is present. Text matching is case-insensitive substring +matching, including phrases, over supplied title/body/labels/paths/identity. + +Unknown operational status is neither success nor failure: "not failed CI" +excludes unknown CI. To retrieve it, use ci:unknown. Missing dates likewise +do not pass a negated date condition. By contrast, "not css" means the css tag +is absent from the supplied index, subject to files_complete. + +The result contains the total within this supplied project snapshot, bounded +items, deterministic nextOffset, and group counts across all matches before +pagination. Multi-valued facet group counts can sum above the result total. +Each card is an independent copy; editing a returned card cannot mutate the +index. Duplicate record identities are rejected rather than silently selected. + +## Thousands of PRs + +ProjectIndex builds facet indexes once at ingestion, then filters candidates +before text/path scans. It supports bounded pages (1..100 results) and stable +tie-breaking. The test suite exercises 10,000 supplied records, exclusions, +separate project scopes, total counts, and full-match group counts. + +This is a reference in-memory index, not a database service. Production Cloud +should persist the same facets and full-text fields behind its search API, +using parameterized queries and snapshot/cursor pagination. The first-page +DevContext bootstrap is not a full search corpus. Do not show its capped list +as the total searchable history. + +Host work: + +1. Authorize owner -> project -> durable repository binding before retrieval. +2. Backfill PR/commit metadata with explicit pagination and progress. +3. Update the current projection on PR, review, check, and commit events. +4. Bind checks/reviews to the current PR head and keep observation time. +5. Compile once per query and query the authorized index; do not call an LLM. +6. Return corpus coverage, indexed count, result total, and source freshness. +7. Maintain stable pagination within a snapshot; offset pagination here is for + an unchanged reference index, not a changing remote database. + +A project_id filter is not row-level security. Nano receives authorized data; +it cannot verify who owns a supplied ID. + +## APR memory integration + +APR Project Memory is distinct from the owner-wide graph/Observatory +projection. The semantic annotation should be a rebuildable search projection +attached to a verified record/revision, not a replacement for immutable APR +memory or an alternative identity store. + +At the existing verified memory commit/finalize boundary, a host can classify +the supplied commit subject, changed paths, labels, and resolved PR relation. +Attach the annotation under a versioned namespace, indexed by owner/project, +stable record ID, source SHA, and memory revision. Reprocessing identical +input produces the same annotation hash. Keep structural graph authority and +source verification in the existing APR pipeline. + +Join current PR/review/CI overlays onto that history for search. Update these +overlays when reviews/checks change without pretending the old memory revision +changed. A commit must not be assigned to a PR merely because its words look +similar; use verified provider associations or explicit supplied references. + +The Agent command currently separates memory commit from push: + +```text +aether -m commit --message "Record the verified CSS search work" --link-git +aether -m push +``` + +Publish the source Git commit first so the Gateway can verify it. "-m" selects +Project Memory when it is in first position. Nano does not change this command +or upload a snapshot. The short SHA typed into search is a lookup prefix and +may yield multiple matches; it does not establish an exact memory revision. + +Integration evidence inspected: + +- [APR project/binding and bounded provider reads](https://github.com/AetherAI3/AETHER-CLOUD/blob/186e77a6dc53b4624403d8a3fcba50b4f028f747/docs/reports/2026-09-09-github-apr-project-tooling.md) +- [PR bootstrap and unknown check contract](https://github.com/AetherAI3/AETHER-CLOUD/blob/186e77a6dc53b4624403d8a3fcba50b4f028f747/lib/devcontext/contracts.py) +- [Project Memory implementation boundaries](https://github.com/AetherAI3/AETHER-CLOUD/blob/186e77a6dc53b4624403d8a3fcba50b4f028f747/docs/specs/aether-project-runtime/APR-10-IMPLEMENTATION-REVIEW.md) +- [Agent memory command](https://github.com/AetherAI3/Aether-Agent/blob/ccbe1595e0f1635e6f59b15170a4308282a5745a/docs/project-memory.md) + +## Improvement without an always-running agent + +Store user corrections as overrides and review project aliases explicitly. +Add the corrected example to the corpus, version the vocabulary, and rebuild +annotations. For example, "skins" can become a project-specific alias for CSS: + +```python +from nano.project import DEFAULT_VOCABULARY, Vocabulary +project_words = Vocabulary( + DEFAULT_VOCABULARY.aliases + (("skins", "tech", "css"),), + version="1.0.1", +) +``` + +The query and index must use the same vocabulary hash. This allows useful +local conventions to accumulate without silently changing global meaning. +An optional future model may suggest new aliases or richer summaries; those +are proposals with review/evidence, and are outside this implementation. diff --git a/docs/status.md b/docs/status.md index d3a0a5d..0f75746 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,6 +4,7 @@ Nano is an **alpha reference implementation**. This page distinguishes code that | Area | Status | Evidence and boundary | | --- | --- | --- | +| Project search and semantic annotations | Implemented in Nano; host integration separate | `nano/project/` derives tags with evidence, suggests titles, compiles bounded read-only queries, groups supplied records, and emits compact capsules. The host owns corpus completeness, freshness, GitHub, and APR graph persistence. See [project-intelligence.md](project-intelligence.md). | | `.nano` lexer, parser, and canonical code generation | Implemented | `nano/compiler/` parses the v1.0 grammar and produces `StrategyGraph` (baseline IR) or `NanoModule` (v1.0 IR). | | Static typing and look-ahead protection | Implemented | `nano/types/` types `series`, resolves indicator periods at compile time, and rejects any series offset that is negative or not a compile-time constant. | | Computed indicators | Implemented | `nano/indicators/` ships 40 deterministic kernels with pinned degenerate-case conventions. The feed-signal form still works. | diff --git a/nano/__init__.py b/nano/__init__.py index 1cd7739..8458304 100644 --- a/nano/__init__.py +++ b/nano/__init__.py @@ -12,6 +12,7 @@ |---|---| | ``nano.compiler`` | `.nano` -> IR: lexer, parser, codegen | | ``nano.intent`` | natural language -> independently-versioned host plan | +| ``nano.project`` | project search, evidence-bearing tags, compact agent context | | ``nano.types`` | the type system, and look-ahead protection | | ``nano.indicators`` | typed indicator signatures + deterministic kernels | | ``nano.ir`` | both IR document versions, and the DAG runtimes execute | diff --git a/nano/cli/main.py b/nano/cli/main.py index 835b55d..2c633c0 100644 --- a/nano/cli/main.py +++ b/nano/cli/main.py @@ -44,6 +44,7 @@ command_visualize, ) from .render import FORMATS +from ..project.cli import command_project _EPILOG = """\ exit codes: @@ -231,6 +232,22 @@ def build_parser() -> argparse.ArgumentParser: intent_explain.add_argument("phrase", metavar="PHRASE") intent_explain.set_defaults(handler=command_intent) + project = subcommands.add_parser("project", help="classify and search PR, commit, and memory records") + project_actions = project.add_subparsers(dest="project_action", required=True) + project_parse = project_actions.add_parser("parse", help="compile a read-only project search") + project_parse.add_argument("query") + project_parse.set_defaults(handler=command_project) + project_classify = project_actions.add_parser("classify", help="derive tags and a compact capsule") + project_classify.add_argument("file", type=Path) + project_classify.set_defaults(handler=command_project) + project_search = project_actions.add_parser("search", help="search a supplied JSON record snapshot") + project_search.add_argument("file", type=Path) + project_search.add_argument("query") + project_search.add_argument("--project-id", required=True) + project_search.add_argument("--limit", type=int, default=20) + project_search.add_argument("--offset", type=int, default=0) + project_search.set_defaults(handler=command_project) + version = subcommands.add_parser("version", help="print component versions") version.set_defaults(handler=command_version) diff --git a/nano/project/__init__.py b/nano/project/__init__.py new file mode 100644 index 0000000..458f2c9 --- /dev/null +++ b/nano/project/__init__.py @@ -0,0 +1,13 @@ +"""Deterministic project discovery: tags, search plans, groups, and capsules. + +Nano derives annotations over host-supplied records. Hosts own identity, +authorization, freshness, indexing persistence, GitHub, and memory-graph writes. +""" +from .catalog import DEFAULT_VOCABULARY, VERSION, ProjectError, Vocabulary, canonical_json +from .index import ProjectIndex +from .query import Diagnostic, Predicate, ProjectQuery, compile_query +from .records import ProjectRecord, classify_record + +__all__ = ["DEFAULT_VOCABULARY", "VERSION", "Diagnostic", "Predicate", "ProjectError", + "ProjectIndex", "ProjectQuery", "ProjectRecord", "Vocabulary", + "canonical_json", "classify_record", "compile_query"] diff --git a/nano/project/catalog.py b/nano/project/catalog.py new file mode 100644 index 0000000..5f35453 --- /dev/null +++ b/nano/project/catalog.py @@ -0,0 +1,125 @@ +"""Small, versioned vocabulary shared by project tagging and search.""" +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass + +VERSION = "1.0.0" +SEMANTIC_FIELDS = ("type", "area", "tech", "relation") +STATUS_VALUES = { + "state": ("open", "closed", "merged", "draft", "unknown"), + "review": ("pending", "approved", "changes_requested", "unknown"), + "ci": ("passed", "failed", "running", "queued", "cancelled", "none", "unknown"), +} +KINDS = ("pr", "commit", "memory") +FIELDS = (*SEMANTIC_FIELDS, *STATUS_VALUES, "kind", "repo", "author", "label", "tag", + "number", "sha", "path", "text", "after", "before") +GROUP_FIELDS = (*SEMANTIC_FIELDS, *STATUS_VALUES, "kind", "repo", "author") +SLUG = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") + + +class ProjectError(ValueError): + """Invalid record, vocabulary, or query contract.""" + + +def canonical_json(value: object) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=True, allow_nan=False) + + +def digest(value: object) -> str: + return "sha256:" + hashlib.sha256(canonical_json(value).encode("ascii")).hexdigest() + + +def _entries(field: str, value: str, *words: str) -> tuple: + return tuple((word, field, value) for word in words) + + +@dataclass(frozen=True) +class Vocabulary: + """Pure aliases, never callbacks or learned mutable state. + + Extend aliases with project-specific phrases. New semantic values are + allowed; operational states remain a closed, host-observed vocabulary. + """ + + aliases: tuple[tuple[str, str, str], ...] + version: str = VERSION + + def __post_init__(self) -> None: + if type(self.aliases) is not tuple or not 1 <= len(self.aliases) <= 512: + raise ProjectError("vocabulary requires 1..512 immutable aliases") + if type(self.version) is not str or not self.version: + raise ProjectError("vocabulary version is required") + seen = set() + for item in self.aliases: + if type(item) is not tuple or len(item) != 3: + raise ProjectError("alias must be (phrase, field, value)") + phrase, field, value = item + if not all(type(x) is str for x in item): + raise ProjectError("alias entries must be strings") + if not phrase or len(phrase) > 100 or phrase != " ".join(phrase.casefold().split()): + raise ProjectError("aliases must be normalized lowercase phrases") + if field not in (*SEMANTIC_FIELDS, *STATUS_VALUES, "kind"): + raise ProjectError("aliases may name semantic, status, or kind fields") + if not SLUG.fullmatch(value): + raise ProjectError("alias value must be a slug") + allowed = STATUS_VALUES.get(field, KINDS if field == "kind" else None) + if allowed and value not in allowed: + raise ProjectError("invalid operational alias value") + if phrase in seen: + raise ProjectError(f"alias collision: {phrase}") + seen.add(phrase) + + @property + def content_hash(self) -> str: + return digest({"version": self.version, "aliases": sorted(self.aliases)}) + + def lookup(self, phrase: str) -> tuple[str, str] | None: + phrase = " ".join(phrase.casefold().split()) + return next(((f, v) for p, f, v in self.aliases if p == phrase), None) + + +DEFAULT_VOCABULARY = Vocabulary(( + *_entries("type", "feature", "feature", "features", "feat"), + *_entries("type", "fix", "fix", "fixes", "bug", "bugs", "bugfix"), + *_entries("type", "refactor", "refactor", "refactoring"), + *_entries("type", "docs", "docs", "documentation"), + *_entries("type", "test", "test", "tests", "testing"), + *_entries("type", "chore", "chore", "chores"), + *_entries("area", "frontend", "frontend", "front end", "ui", "ux"), + *_entries("area", "backend", "backend", "back end", "server"), + *_entries("area", "api", "api", "apis"), + *_entries("area", "infra", "infra", "infrastructure", "devops"), + *_entries("area", "security", "security", "auth", "authentication"), + *_entries("area", "memory", "memory", "memory graph"), + *_entries("area", "agent", "agent", "agents"), + *_entries("area", "cli", "cli", "command line"), + *_entries("tech", "css", "css", "styles", "styling", "stylesheet", "stylesheets"), + *_entries("tech", "react", "react", "jsx", "tsx"), + *_entries("tech", "typescript", "typescript", "ts"), + *_entries("tech", "javascript", "javascript", "js"), + *_entries("tech", "python", "python", "py"), + *_entries("tech", "sql", "sql"), + *_entries("relation", "downstream", "downstream"), + *_entries("relation", "upstream", "upstream"), + *_entries("state", "open", "open"), + *_entries("state", "closed", "closed"), + *_entries("state", "merged", "merged", "landed"), + *_entries("state", "draft", "draft", "drafts"), + *_entries("review", "pending", "pending review", "waiting for review", "needs review", + "awaiting review", "unreviewed"), + *_entries("review", "approved", "approved"), + *_entries("review", "changes_requested", "changes requested", "needs changes"), + *_entries("ci", "failed", "failed ci", "ci failed", "failing ci", "failed checks", + "failing checks", "red ci"), + *_entries("ci", "passed", "passed ci", "ci passed", "passing ci", "passing checks", + "green ci"), + *_entries("ci", "running", "ci running", "running ci"), + *_entries("ci", "queued", "ci queued", "queued ci"), + *_entries("kind", "pr", "pr", "prs", "pull request", "pull requests"), + *_entries("kind", "commit", "commit", "commits"), + *_entries("kind", "memory", "memories", "memory records"), +)) diff --git a/nano/project/cli.py b/nano/project/cli.py new file mode 100644 index 0000000..1ff5863 --- /dev/null +++ b/nano/project/cli.py @@ -0,0 +1,40 @@ +"""File I/O adapter for project discovery; the library stays pure.""" +from __future__ import annotations + +import json + +from . import ProjectError, ProjectIndex, canonical_json, classify_record, compile_query + + +def command_project(args, console) -> int: + try: + if args.project_action == "parse": + plan = compile_query(args.query) + console.say(canonical_json(plan.to_dict())) + return 0 if plan.valid else 1 + try: + # Bound the CLI input independently of the in-memory embedding API. + with args.file.open("r", encoding="utf-8-sig") as handle: + text = handle.read(32 * 1024 * 1024 + 1) + if len(text) > 32 * 1024 * 1024: + raise ProjectError("record file exceeds 32 MiB of decoded text") + payload = json.loads(text) + except (OSError, UnicodeError) as exc: + console.warn(f"error: cannot read records: {exc}") + return 3 + except json.JSONDecodeError as exc: + raise ProjectError(f"invalid records JSON: {exc}") from exc + if args.project_action == "classify": + if not isinstance(payload, dict): + raise ProjectError("classify expects one record object") + result = classify_record(payload) + else: + if not isinstance(payload, list): + raise ProjectError("search expects an array of records") + result = ProjectIndex(payload).search( + args.query, project_id=args.project_id, limit=args.limit, offset=args.offset) + console.say(canonical_json(result)) + return 0 + except ProjectError as exc: + console.warn(f"error: {exc}") + return 1 diff --git a/nano/project/fixtures/queries.json b/nano/project/fixtures/queries.json new file mode 100644 index 0000000..50171e4 --- /dev/null +++ b/nano/project/fixtures/queries.json @@ -0,0 +1,33 @@ +[ + { + "source": "frontend css pending review", + "expectedIds": [ + "pr:9001:41" + ] + }, + { + "source": "open PRs with failed CI", + "expectedIds": [ + "pr:9001:42" + ] + }, + { + "source": "css", + "expectedIds": [ + "pr:9001:41", + "memory:9001:css" + ] + }, + { + "source": "memories css", + "expectedIds": [ + "memory:9001:css" + ] + }, + { + "source": "commits memory graph", + "expectedIds": [ + "commit:9001:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } +] diff --git a/nano/project/fixtures/record.json b/nano/project/fixtures/record.json new file mode 100644 index 0000000..c4dda56 --- /dev/null +++ b/nano/project/fixtures/record.json @@ -0,0 +1,23 @@ +{ + "id": "pr:9001:41", + "project_id": "example-project", + "kind": "pr", + "repository": "example/project", + "number": 41, + "author": "brandon", + "title": "feat(frontend): add CSS search filters", + "files": [ + "site/components/search.css" + ], + "source_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "open", + "review": "pending", + "ci": "passed", + "ci_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "review_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "observed_at": "2026-09-16T10:00:00Z", + "updated_at": "2026-09-16T09:00:00Z", + "depends_on": [ + "pr:9001:40" + ] +} diff --git a/nano/project/fixtures/records.json b/nano/project/fixtures/records.json new file mode 100644 index 0000000..d68b020 --- /dev/null +++ b/nano/project/fixtures/records.json @@ -0,0 +1,76 @@ +[ + { + "id": "pr:9001:41", + "project_id": "example-project", + "kind": "pr", + "repository": "example/project", + "number": 41, + "author": "brandon", + "title": "feat(frontend): add CSS search filters", + "files": [ + "site/components/search.css" + ], + "source_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "open", + "review": "pending", + "ci": "passed", + "ci_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "review_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "observed_at": "2026-09-16T10:00:00Z", + "updated_at": "2026-09-16T09:00:00Z", + "depends_on": [ + "pr:9001:40" + ] + }, + { + "id": "pr:9001:42", + "project_id": "example-project", + "kind": "pr", + "repository": "example/project", + "number": 42, + "author": "brandon", + "title": "fix(api): repair project lookup", + "files": [ + "api/routes.py" + ], + "source_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "open", + "review": "approved", + "ci": "failed", + "ci_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "review_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "observed_at": "2026-09-16T10:00:00Z", + "updated_at": "2026-09-16T09:00:00Z", + "depends_on": [] + }, + { + "id": "memory:9001:css", + "project_id": "example-project", + "kind": "memory", + "repository": "example/project", + "title": "CSS conventions for project cards", + "source_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tag_overrides": { + "type": [ + "docs" + ], + "area": [ + "frontend" + ], + "tech": [ + "css" + ] + } + }, + { + "id": "commit:9001:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "project_id": "example-project", + "kind": "commit", + "repository": "example/project", + "title": "docs(memory): document graph labels", + "source_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "files": [ + "docs/memory.md" + ] + } +] diff --git a/nano/project/index.py b/nano/project/index.py new file mode 100644 index 0000000..613fbaa --- /dev/null +++ b/nano/project/index.py @@ -0,0 +1,142 @@ +"""In-memory reference index for a host-authorized project snapshot.""" +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from datetime import timezone +from fnmatch import fnmatchcase +from typing import Iterable, Mapping + +from .catalog import DEFAULT_VOCABULARY, STATUS_VALUES, VERSION, ProjectError, Vocabulary +from .query import Predicate, ProjectQuery, compile_query +from .records import ProjectRecord, classify_record, timestamp + + +@dataclass(frozen=True) +class _Entry: + record: ProjectRecord + annotation: dict + text: str + + +class ProjectIndex: + """Build once on ingestion; search without reclassifying every keystroke. + + Scope is mandatory but is NOT an authorization mechanism. Hosts must supply + only accessible records. This index never contacts GitHub or mutates memory. + """ + + def __init__(self, records: Iterable[ProjectRecord | Mapping], *, + vocabulary: Vocabulary = DEFAULT_VOCABULARY) -> None: + self.vocabulary = vocabulary + self._entries: list[_Entry] = [] + self._facets: dict[tuple[str, str], set[int]] = {} + self._projects: dict[str, set[int]] = {} + seen = set() + for raw in records: + record = raw if isinstance(raw, ProjectRecord) else ProjectRecord.from_dict(raw) + identity = (record.project_id, record.id) + if identity in seen: + raise ProjectError("duplicate record identity; supply one current snapshot per record") + seen.add(identity) + annotation = classify_record(record, vocabulary=vocabulary) + text = " ".join((record.id, record.title, record.body, record.repository, + *record.labels, *record.files)).casefold() + index = len(self._entries) + self._entries.append(_Entry(record, annotation, text)) + self._projects.setdefault(record.project_id, set()).add(index) + values = [(tag["facet"], tag["value"]) for tag in annotation["tags"]] + values += [(f, annotation["status"][f]) for f in STATUS_VALUES] + values += [("kind", record.kind), ("repo", record.repository.casefold()), + ("author", record.author.casefold())] + if record.number is not None: + values.append(("number", str(record.number))) + values += [("label", label.casefold()) for label in record.labels] + values += [("tag", tag["value"]) for tag in annotation["tags"]] + for field, value in values: + self._facets.setdefault((field, value), set()).add(index) + + def _matching(self, predicate: Predicate, candidates: set[int]) -> set[int]: + field, value = predicate.field, predicate.value + unknown: set[int] = set() + if field == "text": + found = {i for i in candidates if value in self._entries[i].text} + elif field == "sha": + found = {i for i in candidates if self._entries[i].record.source_sha.startswith(value)} + elif field == "path": + found = {i for i in candidates if any( + fnmatchcase(path.replace("\\", "/").casefold(), value) + if "*" in value or "?" in value else path.replace("\\", "/").casefold().startswith(value) + for path in self._entries[i].record.files)} + elif field in ("after", "before"): + found = set() + for i in candidates: + updated = timestamp(self._entries[i].record.updated_at) + if updated is None: + unknown.add(i) + else: + day = updated.astimezone(timezone.utc).date().isoformat() + if (day >= value if field == "after" else day < value): + found.add(i) + else: + found = candidates & self._facets.get((field, value), set()) + if field in STATUS_VALUES and value != "unknown": + unknown = self._facets.get((field, "unknown"), set()) + return candidates - found - unknown if predicate.exclude else found + + def search(self, query: str | ProjectQuery, *, project_id: str, + limit: int = 20, offset: int = 0) -> dict: + if type(project_id) is not str or not project_id: + raise ProjectError("an explicit project_id scope is required") + if type(limit) is not int or not 1 <= limit <= 100: + raise ProjectError("limit must be 1..100") + if type(offset) is not int or offset < 0: + raise ProjectError("offset must be a nonnegative integer") + plan = compile_query(query, vocabulary=self.vocabulary) if isinstance(query, str) else query + if not isinstance(plan, ProjectQuery) or not plan.valid: + raise ProjectError("query has diagnostics; resolve them before searching") + if plan.vocabulary_hash != self.vocabulary.content_hash: + raise ProjectError("query and index vocabulary hashes differ") + scope = self._projects.get(project_id, set()) + matches = set() + for clause in plan.clauses: + candidates = set(scope) + # Apply indexed predicates first, preserving equivalent AND semantics. + for predicate in sorted(clause, key=lambda p: p.field in ("text", "path", "sha", "after", "before")): + candidates = self._matching(predicate, candidates) + if not candidates: + break + matches |= candidates + + def sort_key(index: int) -> tuple: + record = self._entries[index].record + if plan.sort == "title": + return record.title.casefold(), record.id + if plan.sort == "number": + return record.number is None, -(record.number or 0), record.id + updated = timestamp(record.updated_at) + return updated is None, -(updated.timestamp() if updated else 0), record.id + + ordered = sorted(matches, key=sort_key) + groups: dict[str, int] = {} + if plan.group_by: + for index in matches: + entry = self._entries[index] + field = plan.group_by + if field in STATUS_VALUES: + values = [entry.annotation["status"][field]] + elif field in ("repo", "author", "kind"): + values = [getattr(entry.record, "repository" if field == "repo" else field) or "unknown"] + else: + values = [tag["value"] for tag in entry.annotation["tags"] if tag["facet"] == field] or ["unknown"] + for value in set(values): + groups[value] = groups.get(value, 0) + 1 + page = ordered[offset:offset + limit] + return { + "nanoProjectVersion": VERSION, "projectId": project_id, + "queryHash": plan.content_hash, "query": plan.to_dict(), + "total": len(ordered), "offset": offset, "limit": limit, + "nextOffset": offset + limit if offset + limit < len(ordered) else None, + "groups": [{"value": value, "count": count} for value, count in sorted(groups.items())], + "items": [deepcopy(self._entries[i].annotation) for i in page], + } diff --git a/nano/project/query.py b/nano/project/query.py new file mode 100644 index 0000000..4fc81d6 --- /dev/null +++ b/nano/project/query.py @@ -0,0 +1,256 @@ +"""Bounded read-only project query compilation with explicit Boolean clauses.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date + +from .catalog import (DEFAULT_VOCABULARY, FIELDS, GROUP_FIELDS, KINDS, + STATUS_VALUES, VERSION, ProjectError, Vocabulary, digest) + +_FIELD_ALIASES = {"technology": "tech", "repository": "repo", "status": "state"} +_FILLERS = {"show", "find", "search", "me", "the", "with", "that", "are", "all"} +_VALUE_ALIASES = {"review": {"pending_review": "pending", "changes": "changes_requested"}, + "ci": {"pass": "passed", "fail": "failed", "green": "passed", "red": "failed"}, + "kind": {"pull_request": "pr", "prs": "pr"}} + + +@dataclass(frozen=True) +class Predicate: + field: str + value: str + exclude: bool = False + + def __post_init__(self) -> None: + if self.field not in FIELDS or type(self.value) is not str or not self.value or len(self.value) > 512: + raise ProjectError("invalid query predicate") + if type(self.exclude) is not bool: + raise ProjectError("exclude must be boolean") + allowed = STATUS_VALUES.get(self.field, KINDS if self.field == "kind" else None) + if allowed and self.value not in allowed: + raise ProjectError(f"unknown {self.field} value: {self.value}") + if self.field == "number" and not re.fullmatch(r"[1-9][0-9]{0,9}", self.value): + raise ProjectError("number must be a positive PR number") + if self.field == "sha" and not re.fullmatch(r"[0-9a-f]{7,64}", self.value): + raise ProjectError("sha requires 7..64 lowercase hex characters") + if self.field in ("after", "before"): + try: + if date.fromisoformat(self.value).isoformat() != self.value: + raise ValueError() + except ValueError as exc: + raise ProjectError("date filters require YYYY-MM-DD") from exc + + def to_dict(self) -> dict: + return {"field": self.field, "value": self.value, "exclude": self.exclude} + + +@dataclass(frozen=True) +class Diagnostic: + code: str + message: str + start: int + end: int + + def to_dict(self) -> dict: + return {"code": self.code, "message": self.message, "span": [self.start, self.end]} + + +@dataclass(frozen=True) +class ProjectQuery: + source: str + clauses: tuple[tuple[Predicate, ...], ...] + vocabulary_hash: str + group_by: str | None = None + sort: str = "updated" + diagnostics: tuple[Diagnostic, ...] = () + + def __post_init__(self) -> None: + if type(self.source) is not str or len(self.source) > 4096: + raise ProjectError("query source must be text <= 4096 characters") + if type(self.clauses) is not tuple or not 1 <= len(self.clauses) <= 8: + raise ProjectError("queries require 1..8 immutable OR clauses") + if any(type(c) is not tuple or any(not isinstance(p, Predicate) for p in c) for c in self.clauses): + raise ProjectError("clauses must contain immutable predicates") + if sum(map(len, self.clauses)) > 64: + raise ProjectError("queries support at most 64 predicates") + if len(self.clauses) > 1 and any(not c for c in self.clauses): + raise ProjectError("OR clauses must not be empty") + if self.group_by is not None and self.group_by not in GROUP_FIELDS: + raise ProjectError("invalid grouping field") + if self.sort not in ("updated", "number", "title"): + raise ProjectError("invalid sort") + if type(self.diagnostics) is not tuple or any(not isinstance(d, Diagnostic) for d in self.diagnostics): + raise ProjectError("diagnostics must be immutable") + + @property + def valid(self) -> bool: + return not self.diagnostics + + def to_dict(self) -> dict: + return {"nanoProjectQueryVersion": VERSION, "source": self.source, + "vocabularyHash": self.vocabulary_hash, "valid": self.valid, + "anyOf": [{"allOf": [p.to_dict() for p in c]} for c in self.clauses], + "groupBy": self.group_by, "sort": self.sort, + "diagnostics": [d.to_dict() for d in self.diagnostics]} + + @property + def content_hash(self) -> str: + return digest(self.to_dict()) + + +@dataclass(frozen=True) +class _Token: + value: str + literal: bool + start: int + end: int + tag: bool = False + + +def _tokens(source: str) -> tuple[list[_Token], list[Diagnostic]]: + result, diagnostics = [], [] + i = 0 + while i < len(source): + if source[i].isspace(): + i += 1 + continue + start, value, in_quote = i, [], False + if source[i] == "[" or source[i:i + 2] == "-[": + bracket = i + (source[i] == "-") + close = source.find("]", bracket + 1) + if close < 0: + close = len(source) + diagnostics.append(Diagnostic("UNCLOSED_TAG", "Close the bracketed tag.", start, close)) + value = source[bracket + 1:close].strip() + result.append(_Token(("-" if bracket != i else "") + value, False, start, + min(close + 1, len(source)), True)) + i = close + 1 + if len(result) > 256: + raise ProjectError("queries support at most 256 tokens") + continue + literal = source[i] == '"' or source[i:i + 2] == '-"' + while i < len(source) and (in_quote or not source[i].isspace()): + char = source[i] + if not in_quote and char == "[": + break + if char == '"': + in_quote = not in_quote + elif in_quote and char == "\\" and i + 1 < len(source) and source[i + 1] in '\\"': + i += 1 + value.append(source[i]) + else: + value.append(char) + i += 1 + if in_quote: + diagnostics.append(Diagnostic("UNCLOSED_QUOTE", "Close the quoted search phrase.", start, i)) + result.append(_Token("".join(value), literal, start, i)) + if len(result) > 256: + raise ProjectError("queries support at most 256 tokens") + return result, diagnostics + + +def compile_query(source: str, *, vocabulary: Vocabulary = DEFAULT_VOCABULARY) -> ProjectQuery: + """Compile natural phrases or explicit filters. Unknown words stay text. + + Clauses are OR-ed; predicates in a clause are AND-ed. There is no I/O, + implicit current time, generated SQL, permission expansion, or model call. + """ + if type(source) is not str or len(source) > 4096: + raise ProjectError("query source must be text <= 4096 characters") + try: + source.encode("utf-8") + except UnicodeEncodeError as exc: + raise ProjectError("invalid Unicode") from exc + tokens, diagnostics = _tokens(source) + clauses: list[list[Predicate]] = [[]] + group_by, sort = None, "updated" + i, negate, pending_join = 0, False, False + aliases = sorted(vocabulary.aliases, key=lambda item: (-len(item[0].split()), item[0])) + + def error(token: _Token, message: str, code: str = "UNSUPPORTED_QUERY") -> None: + diagnostics.append(Diagnostic(code, message, token.start, token.end)) + + while i < len(tokens): + token = tokens[i] + word = token.value.casefold() + i += 1 + if not (token.literal or token.tag) and word in ("not", "without", "exclude"): + if negate: + error(token, "Repeated negation is ambiguous.") + negate = True + pending_join = True + continue + if not (token.literal or token.tag) and word in ("and", "or"): + if not clauses[-1] or pending_join or negate: + error(token, "An operator needs a predicate on each side.") + if word == "or" and clauses[-1]: + if len(clauses) >= 8: + raise ProjectError("queries support at most 8 OR clauses") + clauses.append([]) + pending_join = True + continue + if not (token.literal or token.tag) and word in _FILLERS and not negate: + continue + if not (token.literal or token.tag) and word in ("group", "sort") and i < len(tokens) and tokens[i].value.casefold() == "by": + if negate or pending_join: + error(token, "Grouping/sorting cannot complete a Boolean condition.") + i += 1 + if i == len(tokens): + error(token, f"{word} by needs a field.") + continue + field = _FIELD_ALIASES.get(tokens[i].value.casefold(), tokens[i].value.casefold()) + i += 1 + if word == "group": + if field not in GROUP_FIELDS or group_by is not None: + error(token, "Choose one supported grouping field.") + else: + group_by = field + elif field not in ("updated", "number", "title"): + error(token, "Sort by updated, number, or title.") + else: + sort = field + continue + grammatical = re.sub(r'"(?:\\.|[^"\\])*"', '', source[token.start:token.end]) + if not token.literal and any(c in grammatical for c in "()"): + error(token, "Parentheses are not supported; use AND clauses separated by OR, or quote literal text.") + if word.startswith("-") and source[token.start] == "-": + if negate: + error(token, "Repeated negation is ambiguous.") + negate, word = True, word[1:] + field, value = "text", word + if token.tag: + found = vocabulary.lookup(word) + field, value = found if found else ("tag", word) + elif not token.literal and ":" in word: + field, value = word.split(":", 1) + field = _FIELD_ALIASES.get(field, field) + if field not in FIELDS: + error(token, f"Unknown filter {field!r}.", "UNKNOWN_FILTER") + negate, pending_join = False, False + continue + value = _VALUE_ALIASES.get(field, {}).get(value, value) + elif not token.literal and re.fullmatch(r"#\d+", word): + field, value = "number", word[1:] + elif not token.literal: + for alias, candidate_field, candidate_value in aliases: + parts = alias.split() + rest = tokens[i:i + len(parts) - 1] + if [word, *[t.value.casefold() for t in rest]] == parts and not any(t.literal or t.tag for t in rest): + field, value = candidate_field, candidate_value + i += len(parts) - 1 + break + try: + clauses[-1].append(Predicate(field, value, negate)) + except ProjectError as exc: + error(token, str(exc), "INVALID_FILTER") + negate, pending_join = False, False + if sum(map(len, clauses)) > 64: + raise ProjectError("queries support at most 64 predicates") + if negate or pending_join: + token = tokens[-1] + error(token, "The query ends before its condition is complete.") + # Invalid queries never run; retain a valid-shaped diagnostic envelope. + if len(clauses) > 1: + clauses = [c for c in clauses if c] or [[]] + return ProjectQuery(source, tuple(tuple(c) for c in clauses), vocabulary.content_hash, + group_by, sort, tuple(diagnostics)) diff --git a/nano/project/records.py b/nano/project/records.py new file mode 100644 index 0000000..1bb3377 --- /dev/null +++ b/nano/project/records.py @@ -0,0 +1,249 @@ +"""Evidence-bearing annotations over supplied PR, commit, and memory records.""" +from __future__ import annotations + +import re +from dataclasses import dataclass, fields +from datetime import datetime +from pathlib import PurePosixPath +from typing import Mapping + +from .catalog import (DEFAULT_VOCABULARY, KINDS, SEMANTIC_FIELDS, SLUG, + STATUS_VALUES, VERSION, ProjectError, Vocabulary, digest) + +_SHA = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") +_CONVENTIONAL = re.compile(r"^(feat|fix|refactor|docs|test|chore|perf|build|ci)" + r"(?:\(([^)]+)\))?!?:\s*(.+)$", re.I) +_TYPE = {"feat": "feature", "perf": "refactor", "build": "chore", "ci": "chore"} +_PREFIX = {"feature": "feat", "fix": "fix", "refactor": "refactor", + "docs": "docs", "test": "test", "chore": "chore"} + + +def timestamp(value: str) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timezone missing") + return parsed + except (TypeError, ValueError) as exc: + raise ProjectError("timestamps must be ISO-8601 with an explicit timezone") from exc + + +def _text(value: object, name: str, maximum: int, *, required: bool = False) -> None: + if type(value) is not str or len(value) > maximum or (required and not value.strip()): + raise ProjectError(f"{name} must be {'nonempty ' if required else ''}text <= {maximum} characters") + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ProjectError(f"{name} contains invalid Unicode") from exc + if any(ord(c) < 32 and c not in "\n\r\t" for c in value): + raise ProjectError(f"{name} contains control characters") + + +@dataclass(frozen=True) +class ProjectRecord: + id: str + project_id: str + kind: str + title: str + repository: str = "" + number: int | None = None + author: str = "" + body: str = "" + files: tuple[str, ...] = () + labels: tuple[str, ...] = () + source_sha: str = "" + state: str = "unknown" + review: str = "unknown" + ci: str = "unknown" + ci_sha: str = "" + review_sha: str = "" + observed_at: str = "" + updated_at: str = "" + depends_on: tuple[str, ...] = () + blocks: tuple[str, ...] = () + tag_overrides: tuple[tuple[str, tuple[str, ...]], ...] = () + files_complete: bool = False + + def __post_init__(self) -> None: + for name, maximum in (("id", 256), ("project_id", 128), ("title", 500), + ("repository", 200), ("author", 100), ("body", 16000)): + _text(getattr(self, name), name, maximum, required=name in ("id", "project_id", "title")) + for name in ("id", "project_id", "repository", "author"): + if any(c.isspace() for c in getattr(self, name)): + raise ProjectError(f"{name} must not contain whitespace") + if self.kind not in KINDS: + raise ProjectError("kind must be pr, commit, or memory") + if self.number is not None and (type(self.number) is not int or self.number <= 0): + raise ProjectError("number must be a positive integer") + for name, maximum in (("files", 2000), ("labels", 128), ("depends_on", 64), ("blocks", 64)): + values = getattr(self, name) + if type(values) is not tuple or len(values) > maximum: + raise ProjectError(f"{name} must be an immutable bounded tuple") + for item in values: + _text(item, name, 512, required=True) + for field, allowed in STATUS_VALUES.items(): + if getattr(self, field) not in allowed: + raise ProjectError(f"invalid {field}") + for name in ("source_sha", "ci_sha", "review_sha"): + value = getattr(self, name) + if type(value) is not str or (value and not _SHA.fullmatch(value)): + raise ProjectError(f"{name} must be a full lowercase Git SHA") + for name in ("observed_at", "updated_at"): + _text(getattr(self, name), name, 40) + timestamp(getattr(self, name)) + if type(self.files_complete) is not bool: + raise ProjectError("files_complete must be boolean") + if type(self.tag_overrides) is not tuple: + raise ProjectError("tag_overrides must be immutable") + seen = set() + for item in self.tag_overrides: + if type(item) is not tuple or len(item) != 2: + raise ProjectError("invalid tag override") + facet, values = item + if facet not in ("type", "area", "tech") or facet in seen: + raise ProjectError("overrides may replace type, area, or tech once each") + seen.add(facet) + if type(values) is not tuple or len(values) > 16: + raise ProjectError("override values must be a bounded tuple") + if any(type(v) is not str or not SLUG.fullmatch(v) for v in values): + raise ProjectError("override values must be slugs") + + @classmethod + def from_dict(cls, value: Mapping) -> ProjectRecord: + if not isinstance(value, Mapping) or any(type(k) is not str for k in value): + raise ProjectError("record must be an object") + allowed = {f.name for f in fields(cls)} + if set(value) - allowed: + raise ProjectError("unknown record fields: " + ", ".join(sorted(set(value) - allowed))) + data = dict(value) + for name in ("files", "labels", "depends_on", "blocks"): + if name in data: + if type(data[name]) not in (list, tuple): + raise ProjectError(f"{name} must be an array") + data[name] = tuple(data[name]) + if "tag_overrides" in data: + if not isinstance(data["tag_overrides"], Mapping): + raise ProjectError("tag_overrides must be an object") + converted = [] + for facet, values in data["tag_overrides"].items(): + if type(facet) is not str: + raise ProjectError("override fields must be strings") + if type(values) not in (list, tuple): + raise ProjectError("override values must be arrays") + converted.append((facet, tuple(values))) + data["tag_overrides"] = tuple(sorted(converted)) + try: + return cls(**data) + except TypeError as exc: + raise ProjectError(f"invalid record: {exc}") from exc + + +def classify_record(record: ProjectRecord | Mapping, *, + vocabulary: Vocabulary = DEFAULT_VOCABULARY) -> dict: + """Return a derived annotation, not an APR fact or an authorization.""" + if not isinstance(record, ProjectRecord): + record = ProjectRecord.from_dict(record) + evidence: dict[tuple[str, str], set[str]] = {} + + def add(facet: str, value: str, reason: str) -> None: + evidence.setdefault((facet, value), set()).add(reason) + + def phrase(text: str, reason: str) -> None: + found = vocabulary.lookup(text) + if found and found[0] in ("type", "area", "tech"): + add(*found, reason) + + for label in record.labels: + normalized = label.strip().casefold() + if ":" in normalized: + facet, value = normalized.split(":", 1) + if facet in ("type", "area", "tech") and SLUG.fullmatch(value.strip()): + add(facet, value.strip(), "label:" + label) + else: + phrase(normalized, "label:" + label) + conventional = _CONVENTIONAL.match(record.title) + if conventional: + kind, scope, _ = conventional.groups() + add("type", _TYPE.get(kind.casefold(), kind.casefold()), "conventional-title") + if scope: + phrase(scope, "title-scope:" + scope) + for bracket in re.findall(r"\[([^\]]{1,100})\]", record.title): + phrase(bracket, "title-tag:" + bracket) + lowered = record.title.casefold() + for alias, facet, value in vocabulary.aliases: + if facet in ("area", "tech") and re.search(r"(? 120 else title} | " + + "".join(f"[{chip}]" for chip in chips[:12]) + + f" | {status['state']} review={status['review']} ci={status['ci']}" + + (f" | sha={record.source_sha[:12]}" if record.source_sha else "") + + f" | asof={record.observed_at or 'unknown'}") + result = { + "nanoProjectVersion": VERSION, "vocabularyHash": vocabulary.content_hash, + "recordId": record.id, "projectId": record.project_id, "kind": record.kind, + "repository": record.repository, "number": record.number, + "title": record.title, "suggestedTitle": suggested, "sourceSha": record.source_sha or None, + "tags": tags, "status": status, "updatedAt": record.updated_at or None, + "dependencies": {"dependsOn": sorted(set(record.depends_on)), "blocks": sorted(set(record.blocks))}, + "filesComplete": record.files_complete, "diagnostics": diagnostics, "capsule": capsule, + } + result["annotationHash"] = digest(result) + return result diff --git a/nano/project/schemas/project-query-1.0.0.schema.json b/nano/project/schemas/project-query-1.0.0.schema.json new file mode 100644 index 0000000..36f0fac --- /dev/null +++ b/nano/project/schemas/project-query-1.0.0.schema.json @@ -0,0 +1,313 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Nano project query 1.0.0", + "type": "object", + "additionalProperties": false, + "required": [ + "nanoProjectQueryVersion", + "source", + "vocabularyHash", + "valid", + "anyOf", + "groupBy", + "sort", + "diagnostics" + ], + "properties": { + "nanoProjectQueryVersion": { + "const": "1.0.0" + }, + "source": { + "type": "string", + "maxLength": 4096 + }, + "vocabularyHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "valid": { + "type": "boolean" + }, + "anyOf": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "allOf" + ], + "properties": { + "allOf": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/predicate" + } + } + } + } + }, + "groupBy": { + "enum": [ + null, + "type", + "area", + "tech", + "relation", + "state", + "review", + "ci", + "kind", + "repo", + "author" + ] + }, + "sort": { + "enum": [ + "updated", + "number", + "title" + ] + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "span" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "span": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "$defs": { + "predicate": { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "value", + "exclude" + ], + "properties": { + "field": { + "enum": [ + "type", + "area", + "tech", + "relation", + "state", + "review", + "ci", + "kind", + "repo", + "author", + "label", + "tag", + "number", + "sha", + "path", + "text", + "after", + "before" + ] + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "exclude": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "field": { + "const": "state" + } + } + }, + "then": { + "properties": { + "value": { + "enum": [ + "open", + "closed", + "merged", + "draft", + "unknown" + ] + } + } + } + }, + { + "if": { + "properties": { + "field": { + "const": "review" + } + } + }, + "then": { + "properties": { + "value": { + "enum": [ + "pending", + "approved", + "changes_requested", + "unknown" + ] + } + } + } + }, + { + "if": { + "properties": { + "field": { + "const": "ci" + } + } + }, + "then": { + "properties": { + "value": { + "enum": [ + "passed", + "failed", + "running", + "queued", + "cancelled", + "none", + "unknown" + ] + } + } + } + }, + { + "if": { + "properties": { + "field": { + "const": "kind" + } + } + }, + "then": { + "properties": { + "value": { + "enum": [ + "pr", + "commit", + "memory" + ] + } + } + } + }, + { + "if": { + "properties": { + "field": { + "const": "number" + } + } + }, + "then": { + "properties": { + "value": { + "pattern": "^[1-9][0-9]{0,9}$" + } + } + } + }, + { + "if": { + "properties": { + "field": { + "const": "sha" + } + } + }, + "then": { + "properties": { + "value": { + "pattern": "^[0-9a-f]{7,64}$" + } + } + } + }, + { + "if": { + "properties": { + "field": { + "enum": [ + "after", + "before" + ] + } + } + }, + "then": { + "properties": { + "value": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "format": "date" + } + } + } + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "valid": { + "const": true + } + } + }, + "then": { + "properties": { + "diagnostics": { + "maxItems": 0 + } + } + }, + "else": { + "properties": { + "diagnostics": { + "minItems": 1 + } + } + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 80cbe45..a78db4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,4 +63,6 @@ nano = [ "library/*/*.json", "intent/schemas/*.json", "intent/fixtures/*.json", + "project/fixtures/*.json", + "project/schemas/*.json", ] diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..ffda961 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,308 @@ +"""Behavioral contracts for read-only project discovery.""" +from __future__ import annotations + +import ast +import json +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from nano.cli.main import main +from nano.project import ( + DEFAULT_VOCABULARY, ProjectError, ProjectIndex, ProjectRecord, Vocabulary, + canonical_json, classify_record, compile_query, +) + +SHA = "a" * 40 +OTHER_SHA = "b" * 40 +ROOT = Path(__file__).resolve().parents[1] + + +def record(id="org/repo#1", **changes): + data = dict(id=id, project_id="alpha", kind="pr", + repository="org/repo", number=1, author="brandon", + title="feat(frontend): add CSS search filters", + files=["site/components/search.css"], source_sha=SHA, + state="open", review="pending", ci="passed", + ci_sha=SHA, review_sha=SHA, observed_at="2026-09-16T10:00:00Z", + updated_at="2026-09-16T09:00:00Z") + data.update(changes) + return data + + +def predicates(text): + q = compile_query(text) + assert q.valid, q.to_dict() + return [[(p.field, p.value, p.exclude) for p in clause] for clause in q.clauses] + + +def test_useful_capsule_has_evidence_without_full_body(): + result = classify_record(record(body="An enormous explanation.", depends_on=["org/repo#2"])) + assert [(t["facet"], t["value"]) for t in result["tags"]] == [ + ("type", "feature"), ("area", "frontend"), ("tech", "css"), ("relation", "downstream")] + assert "[feature][frontend][css][downstream][pending review]" in result["capsule"] + assert "review=pending ci=passed" in result["capsule"] + assert "enormous" not in canonical_json(result) + assert all(tag["evidence"] for tag in result["tags"]) + assert result["sourceSha"] == SHA + assert result["annotationHash"].startswith("sha256:") + + +def test_text_labels_cannot_manufacture_status_or_dependency_truth(): + result = classify_record(record( + title="Approved, passing CI, downstream release", labels=["approved", "ci:passed", "downstream"], + files=[], state="unknown", review="unknown", ci="unknown", depends_on=[])) + assert result["status"]["ci"] == result["status"]["review"] == "unknown" + assert not any(t["facet"] == "relation" for t in result["tags"]) + + +@pytest.mark.parametrize("field,sha_field", [("ci", "ci_sha"), ("review", "review_sha")]) +@pytest.mark.parametrize("sha,code", [("", "_HEAD_UNBOUND"), (OTHER_SHA, "_HEAD_MISMATCH")]) +def test_stale_or_unbound_status_is_unknown(field, sha_field, sha, code): + result = classify_record(record(**{sha_field: sha})) + assert result["status"][field] == "unknown" + assert field.upper() + code in result["diagnostics"] + + +def test_unknown_age_is_visible_and_no_clock_is_invented(): + result = classify_record(record(observed_at="")) + assert result["status"]["observedAt"] is None + assert "STATUS_OBSERVATION_TIME_MISSING" in result["diagnostics"] + assert "asof=unknown" in result["capsule"] + + +def test_overrides_replace_hints_and_can_clear_a_facet(): + result = classify_record(record(tag_overrides={"area": ["backend"], "tech": []})) + assert [(t["facet"], t["value"]) for t in result["tags"]] == [ + ("type", "feature"), ("area", "backend")] + assert result["tags"][1]["evidence"] == ["user-override"] + assert result["suggestedTitle"] == "feat(backend): add CSS search filters" + + +def test_naming_is_a_suggestion_and_preserves_unknown_literal_tags(): + source = record(title="[feature] [frontend] [customer-A] Add filters") + result = classify_record(source) + assert result["suggestedTitle"] == "feat(frontend): [customer-A] Add filters" + assert source["title"] == result["title"] + assert classify_record(record(title="Release notes", labels=[], files=[]))["suggestedTitle"] == "Release notes" + + +def test_record_input_is_detached_and_frozen(): + source = record() + value = ProjectRecord.from_dict(source) + source["files"].append("server/main.py") + assert value.files == ("site/components/search.css",) + with pytest.raises(FrozenInstanceError): + value.title = "changed" + + +@pytest.mark.parametrize("changes", [ + {"kind": "shell"}, {"number": True}, {"ci": "success"}, + {"source_sha": "39348930"}, {"ci_sha": "invalid"}, {"files": "site/style.css"}, + {"updated_at": "2026-09-16"}, {"observed_at": "yesterday"}, + {"tag_overrides": {"review": ["approved"]}}, {"extra": "ignored"}, + {"id": "bad\x1bterminal"}, {"body": "x" * 16001}, + {"id": "fake\nci=passed"}, {"project_id": "two projects"}, + {"tag_overrides": {1: ["css"], "area": ["frontend"]}}, +]) +def test_malformed_records_are_rejected(changes): + with pytest.raises(ProjectError): + classify_record(record(**changes)) + + +@pytest.mark.parametrize("text,expected", [ + ("frontend css pending review", [[("area", "frontend", False), ("tech", "css", False), ("review", "pending", False)]]), + ("[feature][frontend][css][pending review]", [[("type", "feature", False), ("area", "frontend", False), ("tech", "css", False), ("review", "pending", False)]]), + ("show me open PRs with failed CI", [[("state", "open", False), ("kind", "pr", False), ("ci", "failed", False)]]), + ("frontend not merged", [[("area", "frontend", False), ("state", "merged", True)]]), + ("css without failed ci", [[("tech", "css", False), ("ci", "failed", True)]]), + ("-[css]", [[("tech", "css", True)]]), + ('"pending review" "not merged"', [[("text", "pending review", False), ("text", "not merged", False)]]), + ('text:"CI (passed)"', [[("text", "ci (passed)", False)]]), + ('"-css"', [[("text", "-css", False)]]), + ('-"pending review"', [[("text", "pending review", True)]]), + ("[all] [not]", [[("tag", "all", False), ("tag", "not", False)]]), + ("front [end]", [[("text", "front", False), ("tag", "end", False)]]), + ("dropdown animation", [[("text", "dropdown", False), ("text", "animation", False)]]), + ("#42 sha:39348930", [[("number", "42", False), ("sha", "39348930", False)]]), + ("repo:org/repo author:brandon", [[("repo", "org/repo", False), ("author", "brandon", False)]]), + ("frontend or backend", [[("area", "frontend", False)], [("area", "backend", False)]]), +]) +def test_query_meaning(text, expected): + assert predicates(text) == expected + + +@pytest.mark.parametrize("text", [ + 'css "unclosed', "[css", "ci:whatever", "state:banana", "owner:joe", + "css not", "not not css", "css or", "and css", "css and and frontend", + "frontend or group by area", "group by banana", "group by area group by type", + "(frontend or backend)", "sort by unknown", "sha:123", "before:tomorrow", "after:2026-02-30", +]) +def test_unresolved_query_never_silently_runs(text): + query = compile_query(text) + assert not query.valid + assert query.diagnostics + with pytest.raises(ProjectError, match="diagnostics"): + ProjectIndex([record()]).search(query, project_id="alpha") + + +def test_exclusions_and_or_use_complete_meaning_and_unknown_is_not_success(): + records = [ + record("pass"), + record("fail", ci="failed"), + record("unknown", ci="unknown"), + record("merged", state="merged"), + record("other", title="fix(api): repair endpoint", files=["api/routes.py"], ci="failed"), + ] + index = ProjectIndex(records) + assert {x["recordId"] for x in index.search("css not failed ci not merged", project_id="alpha")["items"]} == {"pass"} + assert {x["recordId"] for x in index.search("ci:unknown", project_id="alpha")["items"]} == {"unknown"} + result = index.search("css or api", project_id="alpha") + assert result["total"] == 5 + assert len({x["recordId"] for x in result["items"]}) == 5 + + +def test_unknown_state_does_not_satisfy_not_merged(): + index = ProjectIndex([record(state="unknown")]) + assert index.search("not merged", project_id="alpha")["total"] == 0 + + +def test_group_counts_cover_all_matches_before_pagination(): + index = ProjectIndex([record(str(n), number=n + 1, review="approved" if n < 3 else "pending") for n in range(8)]) + first = index.search("css group by review sort by number", project_id="alpha", limit=2) + second = index.search("css group by review sort by number", project_id="alpha", limit=2, offset=2) + assert first["total"] == 8 and len(first["items"]) == 2 + assert first["groups"] == [{"value": "approved", "count": 3}, {"value": "pending", "count": 5}] + assert [r["number"] for r in first["items"]] == [8, 7] + assert [r["number"] for r in second["items"]] == [6, 5] + assert first["nextOffset"] == 2 + + +def test_scope_is_mandatory_and_never_expanded_by_query_text(): + index = ProjectIndex([record("a"), record("b", project_id="beta")]) + assert index.search("", project_id="alpha")["total"] == 1 + assert index.search("beta", project_id="alpha")["total"] == 0 + assert index.search("", project_id="missing")["total"] == 0 + with pytest.raises(ProjectError): + index.search("", project_id="") + + +def test_date_filters_are_utc_anchored_without_an_ambient_clock(): + index = ProjectIndex([ + record("late", updated_at="2026-09-16T23:30:00-04:00"), + record("early", updated_at="2026-09-16T01:00:00Z"), + record("unknown", updated_at=""), + ]) + assert [x["recordId"] for x in index.search("after:2026-09-17", project_id="alpha")["items"]] == ["late"] + assert [x["recordId"] for x in index.search("before:2026-09-17", project_id="alpha")["items"]] == ["early"] + assert index.search("not after:2026-09-17", project_id="alpha")["total"] == 1 + + +def test_paths_labels_shas_and_literals_remain_searchable(): + index = ProjectIndex([record(body='Keep "Hello NASA"', labels=["customer:critical"])]) + for query in ('path:site/', 'path:*.css', 'label:customer:critical', f'sha:{SHA[:8]}', + 'tag:css', '"Hello NASA"'): + assert index.search(query, project_id="alpha")["total"] == 1 + + +def test_returned_cards_cannot_mutate_the_index(): + index = ProjectIndex([record()]) + first = index.search("", project_id="alpha") + first["items"][0]["status"]["ci"] = "failed" + assert index.search("", project_id="alpha")["items"][0]["status"]["ci"] == "passed" + + +def test_versioned_aliases_make_project_corrections_explicit(): + custom = Vocabulary(DEFAULT_VOCABULARY.aliases + (("skins", "tech", "css"),), version="1.0.1") + index = ProjectIndex([record()], vocabulary=custom) + assert index.search("skins", project_id="alpha")["total"] == 1 + assert classify_record(record(title="feat: skins"), vocabulary=custom)["vocabularyHash"] == custom.content_hash + with pytest.raises(ProjectError, match="hashes differ"): + index.search(compile_query("css"), project_id="alpha") + with pytest.raises(ProjectError, match="collision"): + Vocabulary(DEFAULT_VOCABULARY.aliases + (("css", "area", "backend"),)) + + +def test_packaged_corpus_searches_prs_commits_and_memory(): + fixtures = ROOT / "nano/project/fixtures" + records = json.loads((fixtures / "records.json").read_text()) + index = ProjectIndex(records) + for example in json.loads((fixtures / "queries.json").read_text()): + result = index.search(example["source"], project_id="example-project") + assert [item["recordId"] for item in result["items"]] == example["expectedIds"] + + +def test_annotation_and_search_replay_ignore_ingestion_order(): + first = record(labels=["frontend", "feature"], files=["site/a.css", "site/b.css"]) + reordered = dict(first, labels=list(reversed(first["labels"])), files=list(reversed(first["files"]))) + assert classify_record(first) == classify_record(reordered) + entries = [first, record("second", number=2), record("third", ci="failed")] + left = ProjectIndex(entries).search("css group by ci", project_id="alpha") + right = ProjectIndex(reversed(entries)).search("css group by ci", project_id="alpha") + assert canonical_json(left) == canonical_json(right) + reordered_words = Vocabulary(tuple(reversed(DEFAULT_VOCABULARY.aliases))) + assert compile_query("frontend css", vocabulary=reordered_words) == compile_query("frontend css") + + +def test_duplicate_identity_is_not_arbitrarily_selected(): + with pytest.raises(ProjectError, match="duplicate"): + ProjectIndex([record(), record(title="different")]) + + +@pytest.mark.parametrize("limit,offset", [(0, 0), (101, 0), (True, 0), (20, -1)]) +def test_pagination_is_bounded(limit, offset): + with pytest.raises(ProjectError): + ProjectIndex([record()]).search("", project_id="alpha", limit=limit, offset=offset) + + +def test_bounded_query_work(): + for text in ("x" * 4097, " ".join("x" for _ in range(257)), " or ".join("css" for _ in range(9))): + with pytest.raises(ProjectError): + compile_query(text) + + +def test_ten_thousand_records_are_indexed_once_then_filtered_and_paged(): + records = [record(f"org/repo#{n}", number=n, ci="failed" if n % 10 == 0 else "passed", + project_id="beta" if n % 2 == 0 else "alpha") + for n in range(1, 10001)] + index = ProjectIndex(records) + result = index.search("frontend ci:failed group by review", project_id="beta", limit=10) + assert result["total"] == 1000 + assert len(result["items"]) == 10 + assert result["groups"] == [{"value": "pending", "count": 1000}] + assert all(item["projectId"] == "beta" for item in result["items"]) + assert index.search("ci:failed", project_id="alpha")["total"] == 0 + + +def test_cli_parse_classify_and_search(tmp_path, capsys): + assert main(["project", "parse", "frontend css pending review"]) == 0 + assert json.loads(capsys.readouterr().out)["valid"] + assert main(["project", "parse", "css not"]) == 1 + assert not json.loads(capsys.readouterr().out)["valid"] + path = tmp_path / "record.json" + path.write_text(json.dumps(record())) + assert main(["project", "classify", str(path)]) == 0 + assert json.loads(capsys.readouterr().out)["recordId"] == "org/repo#1" + path.write_text(json.dumps([record()])) + assert main(["project", "search", str(path), "frontend", "--project-id", "alpha"]) == 0 + assert json.loads(capsys.readouterr().out)["total"] == 1 + path.write_text("invalid") + assert main(["project", "classify", str(path)]) == 1 + assert "invalid records JSON" in capsys.readouterr().err + assert main(["project", "classify", str(tmp_path / "missing")]) == 3 + + +def test_pure_project_core_has_no_host_network_or_model_dependency(): + banned = {"os", "subprocess", "requests", "httpx", "socket", "urllib", "openai", "anthropic"} + for path in (ROOT / "nano/project").glob("*.py"): + if path.name == "cli.py": + continue + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + assert not any(a.name.split(".")[0] in banned for a in node.names) + elif isinstance(node, ast.ImportFrom): + assert (node.module or "").split(".")[0] not in banned + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + assert node.func.attr not in ("now", "utcnow", "today") From 7d0bee948410f98efbfff7df9d23582d4d90ed39 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 16 Sep 2026 07:04:43 -0400 Subject: [PATCH 2/2] fix(project): retain draft search and breaking title semantics --- docs/project-intelligence.md | 4 ++++ nano/project/index.py | 3 +++ nano/project/records.py | 9 +++++---- tests/test_project.py | 17 +++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/project-intelligence.md b/docs/project-intelligence.md index d56e88e..8ca4f1a 100644 --- a/docs/project-intelligence.md +++ b/docs/project-intelligence.md @@ -132,6 +132,7 @@ record/project identities, full source SHA, original and suggested titles, evidence-bearing tags, status, explicit dependencies, diagnostics, and capsule. annotationHash identifies this derived output; it is not a signature or proof of authenticated input. Naming is a suggestion only; Nano never writes GitHub. +Suggested titles preserve an existing conventional breaking-change marker (!). ## Search contract @@ -152,6 +153,9 @@ tag, number, sha, path, text, after, before. Short aliases: technology -> tech, repository -> repo, status -> state. #41 is number:41. Bracketed chips accept known phrases or a literal tag value. Explicit label filters match labels exactly; tag matches any semantic tag value. +Open includes draft PRs; use "open not draft" to exclude drafts. Lifecycle group +counts keep draft and ordinary open records separate. Hosts use state=draft +only for an open draft, and state=closed/merged for completed PRs. Quoted text is literal, including words such as "not" or "pending review". Unknown bare words remain full-text predicates. Only the documented filler diff --git a/nano/project/index.py b/nano/project/index.py index 613fbaa..6826b9f 100644 --- a/nano/project/index.py +++ b/nano/project/index.py @@ -47,6 +47,9 @@ def __init__(self, records: Iterable[ProjectRecord | Mapping], *, self._projects.setdefault(record.project_id, set()).add(index) values = [(tag["facet"], tag["value"]) for tag in annotation["tags"]] values += [(f, annotation["status"][f]) for f in STATUS_VALUES] + # A draft is still open; "open not draft" excludes drafts. + if annotation["status"]["state"] == "draft": + values.append(("state", "open")) values += [("kind", record.kind), ("repo", record.repository.casefold()), ("author", record.author.casefold())] if record.number is not None: diff --git a/nano/project/records.py b/nano/project/records.py index 1bb3377..81f734c 100644 --- a/nano/project/records.py +++ b/nano/project/records.py @@ -12,7 +12,7 @@ _SHA = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") _CONVENTIONAL = re.compile(r"^(feat|fix|refactor|docs|test|chore|perf|build|ci)" - r"(?:\(([^)]+)\))?!?:\s*(.+)$", re.I) + r"(?:\(([^)]+)\))?(!?):\s*(.+)$", re.I) _TYPE = {"feat": "feature", "perf": "refactor", "build": "chore", "ci": "chore"} _PREFIX = {"feature": "feat", "fix": "fix", "refactor": "refactor", "docs": "docs", "test": "test", "chore": "chore"} @@ -165,7 +165,7 @@ def phrase(text: str, reason: str) -> None: phrase(normalized, "label:" + label) conventional = _CONVENTIONAL.match(record.title) if conventional: - kind, scope, _ = conventional.groups() + kind, scope, _, _ = conventional.groups() add("type", _TYPE.get(kind.casefold(), kind.casefold()), "conventional-title") if scope: phrase(scope, "title-scope:" + scope) @@ -213,7 +213,7 @@ def phrase(text: str, reason: str) -> None: if any(v != "unknown" for v in status.values()) and not record.observed_at: diagnostics.append("STATUS_OBSERVATION_TIME_MISSING") status["observedAt"] = record.observed_at or None - base_title = conventional.group(3) if conventional else record.title + base_title = conventional.group(4) if conventional else record.title def remove_semantic_tag(match): found = vocabulary.lookup(match.group(1)) return "" if found and found[0] in ("type", "area", "tech") else match.group(0) @@ -224,7 +224,8 @@ def remove_semantic_tag(match): suggested = record.title if len(types) == 1 and types[0] in _PREFIX and base_title: scope = f"({areas[0]})" if len(areas) == 1 else "" - suggested = f"{_PREFIX[types[0]]}{scope}: {base_title}" + breaking = conventional.group(3) if conventional else "" + suggested = f"{_PREFIX[types[0]]}{scope}{breaking}: {base_title}" chips = [tag["value"] for tag in tags] if status["review"] == "pending": chips.append("pending review") diff --git a/tests/test_project.py b/tests/test_project.py index ffda961..37c2c38 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -88,6 +88,12 @@ def test_naming_is_a_suggestion_and_preserves_unknown_literal_tags(): assert classify_record(record(title="Release notes", labels=[], files=[]))["suggestedTitle"] == "Release notes" +@pytest.mark.parametrize("title", ["feat(frontend)!: replace CSS API", "feat!: replace CSS API"]) +def test_naming_preserves_breaking_change_marker(title): + result = classify_record(record(title=title, tag_overrides={"area": ["frontend"]})) + assert result["suggestedTitle"] == "feat(frontend)!: replace CSS API" + + def test_record_input_is_detached_and_frozen(): source = record() value = ProjectRecord.from_dict(source) @@ -168,6 +174,17 @@ def test_unknown_state_does_not_satisfy_not_merged(): assert index.search("not merged", project_id="alpha")["total"] == 0 +def test_open_includes_drafts_with_explicit_draft_exclusion_available(): + index = ProjectIndex([record("open"), record("draft", state="draft"), + record("closed", state="closed"), record("merged", state="merged"), + record("unknown", state="unknown")]) + result = index.search("open PRs group by state", project_id="alpha") + assert {item["recordId"] for item in result["items"]} == {"open", "draft"} + assert result["groups"] == [{"value": "draft", "count": 1}, {"value": "open", "count": 1}] + assert [item["recordId"] for item in index.search("open not draft", project_id="alpha")["items"]] == ["open"] + assert {item["recordId"] for item in index.search("not open", project_id="alpha")["items"]} == {"closed", "merged"} + + def test_group_counts_cover_all_matches_before_pagination(): index = ProjectIndex([record(str(n), number=n + 1, review="approved" if n < 3 else "pending") for n in range(8)]) first = index.search("css group by review sort by number", project_id="alpha", limit=2)