Type node creation and filter local memory by effective type
- nap_create_node accepts `type`; when omitted (also in nap_bulk and agent-node creates) the type is inferred from the folder and sent explicitly, so older APIs also store typed plans and decisions. Explicit spelling variants are canonicalized; custom types pass as is. - Local browse, full-text and encrypted-shard searches filter on the effective type (canonical spelling, or the folder's type for generic notes; `type=note` excludes notes whose folder implies a type); server rows are post-filtered the same way. Rows expose `effective_type` when it differs from the stored type; discover offers an effective_type facet. - Active-only filters also exclude deployed, released, released_verified_production and folded. Mirrors napseer src/vocab.rs; decision /decisions/2026-09-29-node-type-status-vocabulary. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
611a4571f3
commit
f201cb358b
@@ -66,7 +66,7 @@ DISCOVERY_COMPACT_MAX_LIMIT = 10_000
|
||||
DISCOVERY_FULL_MAX_LIMIT = 10_000
|
||||
DISCOVERY_SOURCE_PAGE_LIMIT = 10_000
|
||||
DISCOVERY_CURSOR_GUARD_PAGES = 100
|
||||
ACTIVE_EXCLUDED_STATUSES = {
|
||||
ACTIVE_EXCLUDED_LIFECYCLE_STATUSES = {
|
||||
"completed",
|
||||
"complete_verified_uncommitted",
|
||||
"complete_pending_human_e2e",
|
||||
@@ -75,6 +75,131 @@ ACTIVE_EXCLUDED_STATUSES = {
|
||||
"archived",
|
||||
"done",
|
||||
}
|
||||
|
||||
# Node type/status vocabulary. This block mirrors napseer's API vocabulary
|
||||
# module (src/vocab.rs; decision /decisions/2026-09-29-node-type-status-vocabulary);
|
||||
# keep the two in step. Stored records are never rewritten: a create without a
|
||||
# type gets the folder's type, and local filters/rows use the *effective* type.
|
||||
CANONICAL_NODE_TYPES = frozenset({
|
||||
"plan",
|
||||
"decision",
|
||||
"implementation_note",
|
||||
"deployment",
|
||||
"documentation",
|
||||
"product_spec",
|
||||
"runbook",
|
||||
"rule",
|
||||
"index",
|
||||
"analysis",
|
||||
"kanban_card",
|
||||
"anchor_route",
|
||||
"note",
|
||||
})
|
||||
# Folder prefixes whose generic notes take a specific type. A prefix matches
|
||||
# the folder itself and anything below it (whole path segments, longest wins).
|
||||
FOLDER_NODE_TYPES = (
|
||||
("/plans", "plan"),
|
||||
("/decisions", "decision"),
|
||||
("/product/decisions", "decision"),
|
||||
("/implementation-notes", "implementation_note"),
|
||||
("/deployments", "deployment"),
|
||||
("/runbooks", "runbook"),
|
||||
("/rules", "rule"),
|
||||
("/indexes", "index"),
|
||||
("/analysis", "analysis"),
|
||||
("/product/analysis", "analysis"),
|
||||
("/documentation", "documentation"),
|
||||
("/kanban", "kanban_card"),
|
||||
)
|
||||
# Release-progress statuses are terminal too. They stay apart from the
|
||||
# lifecycle set (the API keeps them out of its Kanban lifecycle list) and do
|
||||
# not demote search ranking; active-only filters exclude both sets.
|
||||
ACTIVE_EXCLUDED_RELEASE_STATUSES = frozenset({
|
||||
"deployed",
|
||||
"released",
|
||||
"released_verified_production",
|
||||
"folded",
|
||||
})
|
||||
ACTIVE_EXCLUDED_STATUSES = frozenset(ACTIVE_EXCLUDED_LIFECYCLE_STATUSES | ACTIVE_EXCLUDED_RELEASE_STATUSES)
|
||||
|
||||
|
||||
def canonical_node_type(value):
|
||||
"""Spelling-only canonical form: trimmed, lowercase, '-' and ' ' become '_'."""
|
||||
return str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
def normalize_type_folder(folder_path):
|
||||
"""A folder as the server stores it (lowercase slug segments), for type rules."""
|
||||
segments = []
|
||||
for segment in str(folder_path or "").split("/"):
|
||||
segment = re.sub(r"[^a-z0-9._]+", "-", segment.strip().lower()).strip("-")
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
return "/" + "/".join(segments) if segments else None
|
||||
|
||||
|
||||
def folder_node_type(folder_path):
|
||||
"""The type a folder implies for a node that does not name one, or None."""
|
||||
folder = str(folder_path or "")
|
||||
matches = [
|
||||
(prefix, node_type)
|
||||
for prefix, node_type in FOLDER_NODE_TYPES
|
||||
if folder == prefix or folder.startswith(f"{prefix}/")
|
||||
]
|
||||
return max(matches, key=lambda match: len(match[0]))[1] if matches else None
|
||||
|
||||
|
||||
def effective_node_type(node_type, folder_path):
|
||||
"""Canonical stored type, except a generic note resolves from its folder."""
|
||||
canonical = canonical_node_type(node_type)
|
||||
if canonical in ("", "note"):
|
||||
return folder_node_type(folder_path) or "note"
|
||||
return canonical
|
||||
|
||||
|
||||
def explicit_node_type(value):
|
||||
"""A caller's type: known spelling variants canonicalize; custom types stay as given."""
|
||||
canonical = canonical_node_type(value)
|
||||
return canonical if canonical in CANONICAL_NODE_TYPES else value
|
||||
|
||||
|
||||
def create_node_type(value, folder_path):
|
||||
"""Type to send on create; None leaves the server default (no folder rule)."""
|
||||
if value not in (None, ""):
|
||||
return explicit_node_type(value)
|
||||
return folder_node_type(normalize_type_folder(folder_path))
|
||||
|
||||
|
||||
def node_effective_type(node):
|
||||
"""Effective type of a node or row, or None when the row carries no type."""
|
||||
if node.get("effective_type") not in (None, ""):
|
||||
return node["effective_type"]
|
||||
node_type = node.get("type") if "type" in node else node.get("node_type")
|
||||
if node_type is None:
|
||||
return None
|
||||
folder = node.get("folder_path")
|
||||
if folder is None and node.get("full_path"):
|
||||
folder = str(node["full_path"]).rsplit("/", 1)[0] or "/"
|
||||
return effective_node_type(node_type, folder)
|
||||
|
||||
|
||||
def requested_node_types(value):
|
||||
"""Canonical type filter values (a string, comma list, or list)."""
|
||||
return unique_preserve_order(
|
||||
canonical_node_type(item) for item in normalize_string_list(value) if canonical_node_type(item)
|
||||
)
|
||||
|
||||
|
||||
def apply_type_filter(nodes, value):
|
||||
"""Keep rows whose effective type was requested. Older servers match the
|
||||
stored type, so a `note` filter would also return notes whose folder
|
||||
implies another type; rows without a type are left to the server."""
|
||||
types = set(requested_node_types(value))
|
||||
if not types:
|
||||
return nodes
|
||||
return [node for node in nodes if node_effective_type(node) in types | {None}]
|
||||
|
||||
|
||||
KNOWN_LIFECYCLE_STATUSES = {
|
||||
"active",
|
||||
"archived",
|
||||
@@ -6162,7 +6287,7 @@ def status_filter_from_args(args):
|
||||
active_only = as_bool(args.get("active_only"), False)
|
||||
warnings = []
|
||||
for status in statuses + excluded:
|
||||
if status not in KNOWN_LIFECYCLE_STATUSES:
|
||||
if status not in KNOWN_LIFECYCLE_STATUSES and status not in ACTIVE_EXCLUDED_RELEASE_STATUSES:
|
||||
warnings.append(f"unknown metadata.status filter: {status}")
|
||||
return {"status": statuses, "exclude_status": excluded, "active_only": active_only, "warnings": warnings}
|
||||
|
||||
@@ -6263,6 +6388,13 @@ def metadata_summary(node):
|
||||
return {key: metadata[key] for key in unique_preserve_order(keys) if key in metadata}
|
||||
|
||||
|
||||
def reported_effective_type(node):
|
||||
"""effective_type for a compact row: only when it differs from the stored
|
||||
type, so correctly typed rows keep their existing compact shape."""
|
||||
effective = node_effective_type(node)
|
||||
return effective if effective != node.get("type") else None
|
||||
|
||||
|
||||
def summarize_node(node, view="summary", preview_chars=240):
|
||||
node_id = node.get("node_id") or node.get("id")
|
||||
if view == "full":
|
||||
@@ -6281,6 +6413,7 @@ def summarize_node(node, view="summary", preview_chars=240):
|
||||
"node_id": node_id,
|
||||
"full_path": node.get("full_path"),
|
||||
"type": node.get("type"),
|
||||
"effective_type": reported_effective_type(node),
|
||||
"status": status,
|
||||
"date": node_date(node),
|
||||
"created_at": node.get("created_at"),
|
||||
@@ -6296,6 +6429,7 @@ def summarize_node(node, view="summary", preview_chars=240):
|
||||
"project_id": node.get("project_id"),
|
||||
"full_path": node.get("full_path"),
|
||||
"type": node.get("type"),
|
||||
"effective_type": reported_effective_type(node),
|
||||
"name": node.get("name"),
|
||||
"folder_path": node.get("folder_path"),
|
||||
"tags": node.get("tags") or [],
|
||||
@@ -6816,15 +6950,37 @@ def ensure_sqlite_column(conn, table, column, declaration):
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}")
|
||||
|
||||
|
||||
def register_index_functions(conn):
|
||||
"""SQL helpers so local type filters use the one Python vocabulary."""
|
||||
for name, arity, function in (
|
||||
("nap_effective_type", 2, effective_node_type),
|
||||
):
|
||||
try:
|
||||
conn.create_function(name, arity, function, deterministic=True)
|
||||
except sqlite3.NotSupportedError:
|
||||
conn.create_function(name, arity, function)
|
||||
|
||||
|
||||
def open_index_connection():
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
register_index_functions(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def local_index_type_clause(value, alias="n"):
|
||||
"""WHERE clause matching a type filter on effective types only: a generic
|
||||
note whose folder implies another type does not match `note`."""
|
||||
types = requested_node_types(value)
|
||||
if not types:
|
||||
return None, []
|
||||
placeholders = ",".join("?" for _ in types)
|
||||
return f"nap_effective_type({alias}.node_type, {alias}.folder_path) IN ({placeholders})", types
|
||||
|
||||
|
||||
def initialize_index_schema(conn):
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute(
|
||||
@@ -7739,7 +7895,9 @@ def node_matches_search_intent(node, intent):
|
||||
)
|
||||
|
||||
|
||||
SEARCH_DEMOTED_STATUSES = frozenset(ACTIVE_EXCLUDED_STATUSES | {"deprecated", "obsolete"})
|
||||
# Release statuses (deployed, released, ...) are terminal for active-only
|
||||
# lists but often describe the current state, so they do not demote ranking.
|
||||
SEARCH_DEMOTED_STATUSES = frozenset(ACTIVE_EXCLUDED_LIFECYCLE_STATUSES | {"deprecated", "obsolete"})
|
||||
# Weighted relevance. Exact identity (the query is a node's name, title or
|
||||
# basename) is decisive; everything else is additive so that one strong
|
||||
# signal (an authoritative path) cannot hide a node that answers every term.
|
||||
@@ -8125,6 +8283,7 @@ def search_local_index(args):
|
||||
"title": row["title"],
|
||||
"folder_path": row["folder_path"],
|
||||
"type": row["node_type"],
|
||||
"effective_type": effective_node_type(row["node_type"], row["folder_path"]),
|
||||
"tags": json.loads(row["tags_json"] or "[]"),
|
||||
"aliases_text": row["aliases_text"],
|
||||
"aliases": json.loads(row["aliases_json"] or "[]"),
|
||||
@@ -8229,9 +8388,10 @@ def local_index_filter_clauses(args, filters):
|
||||
if args.get("folder_path") not in (None, ""):
|
||||
where.append("n.folder_path = ?")
|
||||
parameters.append(str(args["folder_path"]))
|
||||
if args.get("type") not in (None, ""):
|
||||
where.append("n.node_type = ?")
|
||||
parameters.append(str(args["type"]))
|
||||
type_clause, type_parameters = local_index_type_clause(args.get("type"))
|
||||
if type_clause:
|
||||
where.append(type_clause)
|
||||
parameters.extend(type_parameters)
|
||||
if args.get("tag") not in (None, ""):
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM local_index_node_tags t "
|
||||
@@ -9955,6 +10115,7 @@ def e2e_shard_search(project_id, conn, match_query, analysis, filter_where, filt
|
||||
"title": row["title"],
|
||||
"folder_path": row["folder_path"],
|
||||
"type": row["node_type"],
|
||||
"effective_type": effective_node_type(row["node_type"], row["folder_path"]),
|
||||
"tags": json.loads(row["tags_json"] or "[]"),
|
||||
"aliases": json.loads(row["aliases_json"] or "[]"),
|
||||
"status": row["status"],
|
||||
@@ -12727,7 +12888,6 @@ def node_payload_from_args(args, existing=None):
|
||||
elif not existing:
|
||||
raise RuntimeError("path is required when creating a node")
|
||||
for source, target in [
|
||||
("type", "type"),
|
||||
("content_text", "content_text"),
|
||||
("tags", "tags"),
|
||||
("aliases", "aliases"),
|
||||
@@ -12736,8 +12896,14 @@ def node_payload_from_args(args, existing=None):
|
||||
]:
|
||||
if source in args:
|
||||
payload[target] = args[source]
|
||||
if "type" not in payload and not existing:
|
||||
payload["type"] = "note"
|
||||
if args.get("type") not in (None, ""):
|
||||
payload["type"] = explicit_node_type(args["type"])
|
||||
elif not existing:
|
||||
# A create without a type takes its folder's type; otherwise the
|
||||
# server default applies. Updates never retype a stored node.
|
||||
node_type = create_node_type(None, payload.get("folder_path"))
|
||||
if node_type is not None:
|
||||
payload["type"] = node_type
|
||||
if "content_text" not in payload and not existing:
|
||||
payload["content_text"] = ""
|
||||
if "tags" not in payload and not existing:
|
||||
@@ -12823,13 +12989,16 @@ def create_node(args):
|
||||
payload = {
|
||||
"folder_path": folder_path,
|
||||
"name": args["name"],
|
||||
"type": args.get("type", "note"),
|
||||
"tags": args.get("tags", []),
|
||||
"aliases": args.get("aliases", []),
|
||||
"links": args.get("links", []),
|
||||
"metadata": args.get("metadata", {}),
|
||||
"content_text": args["content_text"],
|
||||
}
|
||||
# Send the folder's type explicitly so older servers store typed nodes too.
|
||||
node_type = create_node_type(args.get("type"), folder_path)
|
||||
if node_type is not None:
|
||||
payload["type"] = node_type
|
||||
node = request_project_write(
|
||||
"POST",
|
||||
f"/v1/projects/{project_id}/nodes",
|
||||
@@ -13069,9 +13238,10 @@ def browse_local_index(args, *, view, sort):
|
||||
if args.get("folder_path") not in (None, ""):
|
||||
where.append("n.folder_path = ?")
|
||||
parameters.append(str(args["folder_path"]))
|
||||
if args.get("type") not in (None, ""):
|
||||
where.append("n.node_type = ?")
|
||||
parameters.append(str(args["type"]))
|
||||
type_clause, type_parameters = local_index_type_clause(args.get("type"))
|
||||
if type_clause:
|
||||
where.append(type_clause)
|
||||
parameters.extend(type_parameters)
|
||||
if args.get("tag") not in (None, ""):
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM local_index_node_tags t "
|
||||
@@ -13143,6 +13313,7 @@ def browse_local_index(args, *, view, sort):
|
||||
"_cursor_title_key": row["title_sort_key"],
|
||||
"folder_path": row["folder_path"],
|
||||
"type": row["node_type"],
|
||||
"effective_type": effective_node_type(row["node_type"], row["folder_path"]),
|
||||
"tags": json.loads(row["tags_json"] or "[]"),
|
||||
"status": status,
|
||||
"metadata_summary": {"status": status} if status else {},
|
||||
@@ -13258,6 +13429,7 @@ def list_project_nodes(args):
|
||||
result = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}")
|
||||
items = decrypt_nodes_for_return(filter_project_nodes(result.get("items", [])), project_id=project_id)
|
||||
items = apply_status_filters(items, filters)
|
||||
items = apply_type_filter(items, base_query.get("type"))
|
||||
raw_q = str(base_query.get("q") or "").strip()
|
||||
query_analysis = result.get("query_analysis")
|
||||
diagnostics = result.get("diagnostics")
|
||||
@@ -15547,9 +15719,12 @@ def discover_memory(args):
|
||||
requested_facets = unique_preserve_order(
|
||||
str(value).strip().lower() for value in (args.get("facets") or []) if str(value).strip()
|
||||
)
|
||||
invalid_facets = sorted(set(requested_facets) - {"folder", "tag", "type", "status"})
|
||||
invalid_facets = sorted(set(requested_facets) - {"folder", "tag", "type", "effective_type", "status"})
|
||||
if invalid_facets:
|
||||
raise RuntimeError("facets must contain only folder, tag, type, or status")
|
||||
raise RuntimeError("facets must contain only folder, tag, type, effective_type, or status")
|
||||
if "type" in requested_facets and "effective_type" not in requested_facets:
|
||||
# "type" keeps counting stored types; effective types ride alongside.
|
||||
requested_facets.append("effective_type")
|
||||
if requested_facets:
|
||||
counts = {}
|
||||
for facet in requested_facets:
|
||||
@@ -15561,6 +15736,8 @@ def discover_memory(args):
|
||||
item_values = item.get("tags") or []
|
||||
elif facet == "type":
|
||||
item_values = [item.get("type")]
|
||||
elif facet == "effective_type":
|
||||
item_values = [node_effective_type(item)]
|
||||
else:
|
||||
item_values = [item.get("status") or node_status(item)]
|
||||
for value in item_values:
|
||||
@@ -18040,7 +18217,7 @@ def raw_tools():
|
||||
"intent": {"type": "string", "enum": ["any", "decision", "rule", "plan", "task", "implementation", "documentation"], "default": "any", "description": "Optional ranking intent. It boosts canonical records of that class without hard-filtering other matches."},
|
||||
"folder_path": {"type": "string"},
|
||||
"tag": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"type": {"type": "string", "description": "Matches effective types: the canonical stored type, except a generic note takes its folder's type (a note under /plans matches plan, not note). Rows keep the stored type and add effective_type when it differs."},
|
||||
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
|
||||
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
|
||||
"since": {"type": "string", "description": "Alias for updated_after."},
|
||||
@@ -18431,6 +18608,15 @@ def raw_tools():
|
||||
"properties": {
|
||||
"folder_path": {"type": "string", "description": "Folder part of the canonical node path, for example /decisions or /incidents/2026-04-26."},
|
||||
"name": {"type": "string", "description": "Human-readable node name. The server normalizes it into full_path."},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Canonical: " + ", ".join(sorted(CANONICAL_NODE_TYPES))
|
||||
+ ". Spelling variants canonicalize; custom types are kept. When omitted, typed folders "
|
||||
"decide (/plans -> plan, /decisions -> decision, /implementation-notes -> implementation_note, "
|
||||
"/runbooks, /rules, /kanban, ...); other folders get note."
|
||||
),
|
||||
},
|
||||
"content_text": {"type": "string"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
"links": {
|
||||
@@ -20155,9 +20341,10 @@ def _efficient_memory_schema(name, schema):
|
||||
}
|
||||
properties["facets"] = {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["folder", "tag", "type", "status"]},
|
||||
"items": {"type": "string", "enum": ["folder", "tag", "type", "effective_type", "status"]},
|
||||
"uniqueItems": True,
|
||||
"maxItems": 4,
|
||||
"maxItems": 5,
|
||||
"description": "Counts over returned rows. type counts stored types; effective_type (also added with type) counts folder-resolved types.",
|
||||
}
|
||||
elif name == "nap_context":
|
||||
for key in ("shortcuts", "paths", "node_ids", "uris"):
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Effective node types and release-terminal statuses in the CLI wrapper.
|
||||
|
||||
Decision /decisions/2026-09-29-node-type-status-vocabulary: creates infer a
|
||||
type from the folder when the caller omits one, type filters match effective
|
||||
types (a generic `note` resolves from its folder), and active-only lists also
|
||||
exclude release-style terminal statuses. Older APIs do none of this, so the
|
||||
wrapper must behave the same on its own. Writes go through the real request
|
||||
path to a fake transport; discovery runs against a disposable HTTP provider
|
||||
and the real local index.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||
|
||||
|
||||
def load_module(name):
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# -- Writes -----------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def read(self, *_args):
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def writer(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||
mod = load_module("napseer_effective_types_write_test")
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
mod.TOKEN = "synthetic-access"
|
||||
mod.TOKEN_EXPIRES_AT = "2099-01-01T00:00:00Z"
|
||||
mod.AUTH = {**mod.AUTH, "account_mode": "operator_account", "project_id": "project-1"}
|
||||
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||
mod.project_memory_encryption_active = lambda _project_id: False
|
||||
mod.AUTH_DIR = tmp_path / "state"
|
||||
mod.INDEX_PATH = tmp_path / "state" / "index.sqlite"
|
||||
mod.INDEX_LOCK_PATH = tmp_path / "state" / "index.lock"
|
||||
mod.resolve_project_id = lambda _args: "project-1"
|
||||
mod.acquire_project_lock = lambda args=None, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
|
||||
mod.cleanup_project_lock = lambda project_id, lock: None
|
||||
sent = []
|
||||
|
||||
def fake_urlopen(request, timeout=None):
|
||||
body = json.loads(request.data.decode("utf-8")) if request.data else None
|
||||
sent.append((request.get_method(), urlsplit(request.full_url).path, body))
|
||||
folder = (body or {}).get("folder_path") or "/"
|
||||
name = (body or {}).get("name") or "node"
|
||||
return FakeResponse({
|
||||
"id": f"node-{len(sent)}", "project_id": "project-1",
|
||||
"full_path": f"{folder.rstrip('/')}/{name}", "folder_path": folder, "name": name,
|
||||
# The fake server applies the historical default; the payload is what is asserted.
|
||||
"type": (body or {}).get("type") or "note",
|
||||
"content_text": (body or {}).get("content_text", ""), "metadata": {},
|
||||
"updated_at": "2026-09-29T12:00:00Z",
|
||||
})
|
||||
|
||||
mod.api_urlopen = fake_urlopen
|
||||
return mod, sent
|
||||
|
||||
|
||||
def created_payload(sent):
|
||||
posts = [body for method, path, body in sent if method == "POST" and path.endswith("/nodes")]
|
||||
assert len(posts) == 1, sent
|
||||
return posts[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder_path", "expected"),
|
||||
[
|
||||
("/plans", "plan"),
|
||||
("/plans/2026", "plan"),
|
||||
("/decisions", "decision"),
|
||||
("/product/decisions", "decision"),
|
||||
("/implementation-notes", "implementation_note"),
|
||||
("/deployments", "deployment"),
|
||||
("/runbooks", "runbook"),
|
||||
("/rules", "rule"),
|
||||
("/indexes", "index"),
|
||||
("/analysis", "analysis"),
|
||||
("/product/analysis", "analysis"),
|
||||
("/documentation/security", "documentation"),
|
||||
("/kanban/doing", "kanban_card"),
|
||||
# The server normalizes folder segments before storing; inference agrees.
|
||||
("/Implementation Notes/", "implementation_note"),
|
||||
],
|
||||
)
|
||||
def test_create_without_type_sends_the_folder_type(writer, folder_path, expected):
|
||||
mod, sent = writer
|
||||
mod.call_tool_impl("nap_create_node", {"folder_path": folder_path, "name": "record", "content_text": "body"})
|
||||
assert created_payload(sent)["type"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("folder_path", ["/notes", "/plansx", "/product", "/services/plans"])
|
||||
def test_create_without_type_outside_typed_folders_leaves_the_server_default(writer, folder_path):
|
||||
mod, sent = writer
|
||||
mod.call_tool_impl("nap_create_node", {"folder_path": folder_path, "name": "record", "content_text": "body"})
|
||||
assert "type" not in created_payload(sent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("given", "expected"),
|
||||
[
|
||||
("implementation-note", "implementation_note"),
|
||||
("Kanban Card", "kanban_card"),
|
||||
("product-spec", "product_spec"),
|
||||
("decision", "decision"),
|
||||
# An explicit generic note stays a note even inside a typed folder.
|
||||
("note", "note"),
|
||||
# Unknown custom types are kept exactly as given.
|
||||
("security-documentation", "security-documentation"),
|
||||
("Custom Type", "Custom Type"),
|
||||
],
|
||||
)
|
||||
def test_create_with_explicit_type_only_canonicalizes_known_spellings(writer, given, expected):
|
||||
mod, sent = writer
|
||||
mod.call_tool_impl(
|
||||
"nap_create_node",
|
||||
{"folder_path": "/plans", "name": "record", "content_text": "body", "type": given},
|
||||
)
|
||||
assert created_payload(sent)["type"] == expected
|
||||
|
||||
|
||||
def test_create_node_schema_advertises_optional_type(writer):
|
||||
mod, _sent = writer
|
||||
tool = next(tool for tool in mod.tools() if tool["name"] == "nap_create_node")
|
||||
schema = tool["inputSchema"]
|
||||
assert schema["properties"]["type"]["type"] == "string"
|
||||
assert "type" not in schema.get("required", [])
|
||||
|
||||
|
||||
def test_bulk_creates_infer_types_per_item(writer):
|
||||
mod, sent = writer
|
||||
mod.try_get_node_by_path = lambda path, allow_agent=False: None
|
||||
mod.call_tool_impl("nap_bulk", {"nodes": [
|
||||
{"path": "/decisions/choose-store", "content_text": "a"},
|
||||
{"path": "/scratch/idea", "content_text": "b"},
|
||||
{"path": "/runbooks/restore", "content_text": "c", "type": "product-spec"},
|
||||
{"path": "/plans/custom", "content_text": "d", "type": "Custom Type"},
|
||||
]})
|
||||
posts = [body for method, path, body in sent if method == "POST" and path.endswith("/nodes")]
|
||||
assert [body.get("type") for body in posts] == ["decision", None, "product_spec", "Custom Type"]
|
||||
assert "type" not in posts[1]
|
||||
|
||||
|
||||
def test_bulk_update_without_type_does_not_retype_the_stored_node(writer):
|
||||
mod, sent = writer
|
||||
existing = {"id": "node-9", "project_id": "project-1", "full_path": "/plans/legacy",
|
||||
"folder_path": "/plans", "name": "legacy", "type": "note", "content_text": "old"}
|
||||
mod.get_node_by_id = lambda args: existing
|
||||
mod.call_tool_impl("nap_bulk", {"nodes": [{"node_id": "node-9", "content_text": "new"}]})
|
||||
patches = [body for method, path, body in sent if method == "PATCH"]
|
||||
assert patches and "type" not in patches[0]
|
||||
|
||||
|
||||
# -- Local discovery ---------------------------------------------------------
|
||||
|
||||
|
||||
def stored_node(folder, name, node_type, *, minute, status=None, content=None):
|
||||
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||
return {
|
||||
"id": name, "project_id": "project-1", "full_path": f"{folder}/{name}",
|
||||
"folder_path": folder, "name": name, "type": node_type,
|
||||
"metadata": {"status": status} if status else {}, "tags": [], "aliases": [], "links": [],
|
||||
"content_text": content or f"{name} vocabularyterm", "updated_at": stamp, "archived_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local(tmp_path, monkeypatch):
|
||||
nodes = {}
|
||||
events = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def reply(self, status, payload):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode())
|
||||
|
||||
def do_GET(self):
|
||||
url = urlsplit(self.path)
|
||||
query = parse_qs(url.query)
|
||||
if url.path.endswith("/changes"):
|
||||
after = query.get("after", ["head"])[0]
|
||||
start = len(events) if after == "head" else int(after[1:])
|
||||
return self.reply(200, {"items": [], "next_cursor": f"c{start}", "has_more": False})
|
||||
if url.path.endswith("/nodes"):
|
||||
rows = sorted((copy.deepcopy(n) for n in nodes.values()),
|
||||
key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||
return self.reply(200, {"items": rows, "next_cursor": None})
|
||||
node_id = url.path.rsplit("/", 1)[-1]
|
||||
if node_id not in nodes:
|
||||
return self.reply(404, {"error": {"code": "not_found", "message": "missing"}})
|
||||
return self.reply(200, copy.deepcopy(nodes[node_id]))
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||
mod = load_module("napseer_effective_types_local_test")
|
||||
mod.AUTH_DIR = tmp_path / "project-state"
|
||||
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
||||
mod.TOKEN = "disposable-test-token"
|
||||
mod.AUTH = {"account_id": "test-account"}
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||
mod.refresh_public_auth_state = lambda: None
|
||||
mod.require_unlocked = lambda _name: None
|
||||
mod.resolve_project_id = lambda args: args.get("project_id", "project-1")
|
||||
mod.project_encryption_state = lambda _project_id: "standard"
|
||||
|
||||
def seed(*items):
|
||||
for item in items:
|
||||
nodes[item["id"]] = copy.deepcopy(item)
|
||||
events.append(item["id"])
|
||||
assert mod.reindex_project({})["graph_complete"] is True
|
||||
|
||||
try:
|
||||
yield mod, seed
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
TYPED_FIXTURE = [
|
||||
stored_node("/plans", "legacy-plan", "note", minute=1),
|
||||
stored_node("/plans/2026", "nested-plan", "note", minute=2),
|
||||
stored_node("/plans", "typed-plan", "plan", minute=3),
|
||||
stored_node("/notes", "scratch", "note", minute=4),
|
||||
stored_node("/implementation-notes", "dashed-note", "implementation-note", minute=5),
|
||||
stored_node("/implementation-notes", "generic-note", "note", minute=6),
|
||||
stored_node("/services", "misplaced-plan", "Plan", minute=7),
|
||||
stored_node("/services", "custom", "security-documentation", minute=8),
|
||||
# A differently spelled generic note still resolves from its folder.
|
||||
stored_node("/decisions", "capital-note", "Note", minute=9),
|
||||
]
|
||||
|
||||
|
||||
def discover(mod, **args):
|
||||
return mod.call_tool_impl("nap_discover", {"mode": "local", "limit": 50, **args})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("q", [None, "vocabularyterm"])
|
||||
def test_local_type_filter_matches_effective_types(local, q):
|
||||
mod, seed = local
|
||||
seed(*TYPED_FIXTURE)
|
||||
extra = {"q": q} if q else {}
|
||||
|
||||
plans = discover(mod, type="plan", **extra)
|
||||
by_id = {item["node_id"]: item for item in plans["items"]}
|
||||
assert set(by_id) == {"legacy-plan", "nested-plan", "typed-plan", "misplaced-plan"}
|
||||
# The stored type is reported unchanged; effective_type is added when it differs.
|
||||
assert (by_id["legacy-plan"]["type"], by_id["legacy-plan"]["effective_type"]) == ("note", "plan")
|
||||
assert (by_id["nested-plan"]["type"], by_id["nested-plan"]["effective_type"]) == ("note", "plan")
|
||||
assert (by_id["misplaced-plan"]["type"], by_id["misplaced-plan"]["effective_type"]) == ("Plan", "plan")
|
||||
assert by_id["typed-plan"]["type"] == "plan"
|
||||
assert "effective_type" not in by_id["typed-plan"]
|
||||
|
||||
notes = discover(mod, type="implementation_note", **extra)
|
||||
by_id = {item["node_id"]: item for item in notes["items"]}
|
||||
assert set(by_id) == {"dashed-note", "generic-note"}
|
||||
assert by_id["dashed-note"]["type"] == "implementation-note"
|
||||
assert {item["effective_type"] for item in notes["items"]} == {"implementation_note"}
|
||||
|
||||
# A spelling variant in the filter canonicalizes the same way.
|
||||
dashed = discover(mod, type="implementation-note", **extra)
|
||||
assert {item["node_id"] for item in dashed["items"]} == {"dashed-note", "generic-note"}
|
||||
|
||||
decisions = discover(mod, type="decision", **extra)
|
||||
assert [item["node_id"] for item in decisions["items"]] == ["capital-note"]
|
||||
assert (decisions["items"][0]["type"], decisions["items"][0]["effective_type"]) == ("Note", "decision")
|
||||
|
||||
custom = discover(mod, type="security_documentation", **extra)
|
||||
assert [item["node_id"] for item in custom["items"]] == ["custom"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("q", [None, "vocabularyterm"])
|
||||
@pytest.mark.parametrize("requested", ["note", "Note", " NOTE "])
|
||||
def test_local_note_filter_is_effective_type_only(local, q, requested):
|
||||
mod, seed = local
|
||||
seed(*TYPED_FIXTURE)
|
||||
extra = {"q": q} if q else {}
|
||||
# Only the note under /notes is a note; notes under /plans, /decisions and
|
||||
# /implementation-notes take their folder's type and are excluded.
|
||||
notes = discover(mod, type=requested, **extra)
|
||||
assert [item["node_id"] for item in notes["items"]] == ["scratch"]
|
||||
assert "effective_type" not in notes["items"][0]
|
||||
|
||||
|
||||
def test_unfiltered_local_rows_carry_effective_type_and_facets(local):
|
||||
mod, seed = local
|
||||
seed(*TYPED_FIXTURE)
|
||||
result = discover(mod, view="paths", facets=["type", "effective_type"])
|
||||
by_id = {item["node_id"]: item for item in result["items"]}
|
||||
assert by_id["legacy-plan"]["type"] == "note"
|
||||
assert by_id["legacy-plan"]["effective_type"] == "plan"
|
||||
assert "effective_type" not in by_id["scratch"]
|
||||
assert by_id["custom"]["effective_type"] == "security_documentation"
|
||||
counts = result["facets"]["counts"]
|
||||
# The stored-type facet keeps its historical meaning.
|
||||
assert {"value": "note", "count": 4} in counts["type"]
|
||||
assert {"value": "plan", "count": 4} in counts["effective_type"]
|
||||
assert {"value": "implementation_note", "count": 2} in counts["effective_type"]
|
||||
|
||||
|
||||
RELEASE_TERMINAL = ["deployed", "released", "released_verified_production", "folded"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("q", [None, "vocabularyterm"])
|
||||
def test_local_active_only_excludes_release_terminal_statuses(local, q):
|
||||
mod, seed = local
|
||||
seed(
|
||||
stored_node("/deployments", "live", "note", minute=1, status="active"),
|
||||
*[
|
||||
stored_node("/deployments", f"release-{status}", "note", minute=2 + index, status=status)
|
||||
for index, status in enumerate(RELEASE_TERMINAL)
|
||||
],
|
||||
stored_node("/plans", "finished", "note", minute=9, status="completed"),
|
||||
)
|
||||
extra = {"q": q} if q else {}
|
||||
active = discover(mod, active_only=True, **extra)
|
||||
assert [item["node_id"] for item in active["items"]] == ["live"]
|
||||
# Without active_only the release records remain discoverable.
|
||||
everything = discover(mod, **extra)
|
||||
assert {f"release-{status}" for status in RELEASE_TERMINAL} <= {
|
||||
item["node_id"] for item in everything["items"]
|
||||
}
|
||||
|
||||
|
||||
def test_server_rows_are_post_filtered_on_effective_type(local):
|
||||
mod, _seed = local
|
||||
# An older server matches stored types only, so type=note returns every
|
||||
# stored note; the wrapper keeps just the effective notes.
|
||||
rows = [
|
||||
{"id": "under-plans", "full_path": "/plans/legacy", "folder_path": "/plans", "type": "note"},
|
||||
{"id": "under-decisions", "full_path": "/decisions/choice", "type": "Note"},
|
||||
{"id": "under-notes", "full_path": "/notes/scratch", "folder_path": "/notes", "type": "note"},
|
||||
]
|
||||
requests = []
|
||||
|
||||
def fake_request(method, path, payload=None, **kwargs):
|
||||
requests.append(path)
|
||||
return {"items": copy.deepcopy(rows), "next_cursor": None}
|
||||
|
||||
mod.request_json = fake_request
|
||||
notes = mod.list_project_nodes({"type": "note", "view": "paths"})
|
||||
assert "type=note" in requests[-1]
|
||||
assert [item["node_id"] for item in notes["items"]] == ["under-notes"]
|
||||
|
||||
rows[:] = [{"id": "typed", "full_path": "/plans/typed", "type": "plan"},
|
||||
{"id": "legacy", "full_path": "/plans/legacy", "type": "note"}]
|
||||
plans = mod.list_project_nodes({"type": "plan", "view": "paths"})
|
||||
assert [item["node_id"] for item in plans["items"]] == ["typed", "legacy"]
|
||||
|
||||
|
||||
def test_active_only_filter_on_server_rows_excludes_release_statuses(local):
|
||||
mod, _seed = local
|
||||
rows = [{"id": status, "metadata": {"status": status}} for status in [*RELEASE_TERMINAL, "active", "done"]]
|
||||
filters = mod.status_filter_from_args({"active_only": True})
|
||||
# "done" keeps its existing CLI handling (already excluded).
|
||||
assert [row["id"] for row in mod.apply_status_filters(rows, filters)] == ["active"]
|
||||
explicit = mod.status_filter_from_args({"status": "deployed"})
|
||||
assert explicit["warnings"] == []
|
||||
@@ -52,18 +52,18 @@ def unlock(mod, secret=None):
|
||||
cache_secret(mod, secret or memory_secret())
|
||||
|
||||
|
||||
def plain_node(name, minute, content=None):
|
||||
def plain_node(name, minute, content=None, folder="/notes"):
|
||||
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||
return {
|
||||
"id": name, "project_id": PROJECT, "full_path": f"/notes/{name}", "folder_path": "/notes",
|
||||
"id": name, "project_id": PROJECT, "full_path": f"{folder}/{name}", "folder_path": folder,
|
||||
"name": name, "type": "note", "metadata": {}, "tags": [], "aliases": [], "links": [],
|
||||
"content_text": content if content is not None else name, "updated_at": stamp, "archived_at": None,
|
||||
}
|
||||
|
||||
|
||||
def e2e_node(mod, name, minute, content, metadata=None, secret=None, archived=False):
|
||||
def e2e_node(mod, name, minute, content, metadata=None, secret=None, archived=False, folder="/notes"):
|
||||
secret = secret or memory_secret()
|
||||
node = plain_node(name, minute)
|
||||
node = plain_node(name, minute, folder=folder)
|
||||
context = {"full_path": node["full_path"]}
|
||||
node.update({
|
||||
"content_text": "", "metadata": {}, "encryption_state": "encrypted",
|
||||
@@ -229,6 +229,22 @@ def test_e2e_body_term_is_findable_with_snippet_while_unlocked(env):
|
||||
assert not discovered.get("warnings")
|
||||
|
||||
|
||||
def test_e2e_shard_hits_filter_on_effective_type(env):
|
||||
mod, provider, _ = env
|
||||
provider.put(e2e_node(mod, "legacyplan", 1, BODY, folder="/plans"), "node_created")
|
||||
provider.put(e2e_node(mod, "scratchnote", 2, BODY), "node_created")
|
||||
assert mod.reindex_project({})["e2e_index"]["status"] == "applied"
|
||||
|
||||
# Bodies are only in the shard, so these hits come from the E2E path.
|
||||
plans = mod.search_local_index({"q": BODY_TERM, "limit": 10, "type": "plan"})
|
||||
assert hit_ids(plans) == ["legacyplan"]
|
||||
assert plans["items"][0]["search_source"] == "local_e2e"
|
||||
assert (plans["items"][0]["type"], plans["items"][0]["effective_type"]) == ("note", "plan")
|
||||
notes = mod.search_local_index({"q": BODY_TERM, "limit": 10, "type": "note"})
|
||||
assert hit_ids(notes) == ["scratchnote"]
|
||||
assert notes["items"][0]["search_source"] == "local_e2e"
|
||||
|
||||
|
||||
def test_locked_secret_yields_no_e2e_hits_and_an_explicit_warning(env):
|
||||
mod, provider, _ = env
|
||||
seed(mod, provider)
|
||||
|
||||
@@ -305,6 +305,8 @@ def run():
|
||||
"node_id": "done",
|
||||
"full_path": "/plans/done",
|
||||
"type": "note",
|
||||
# A generic note under /plans reports its effective type as well.
|
||||
"effective_type": "plan",
|
||||
"status": "completed",
|
||||
}]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user