Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,34 @@ an empty list from one provider must not vouch for partitions another provider n
`basePath` now becomes `namespace:{base} scope:descendants` unless the query states its own
`scope:`. "Search from here" means the subtree.

### `basePath` never rewrites the query's own location

A query that names its own location (`path:`, `namespace:`, a membership or wildcard namespace)
**keeps** it when that location lies at or under the base — it is the narrower statement — and is
**refused**, naming both, when it lies outside: no answer can honour both. Only a query that names
no location of its own (an exclusion like `-namespace:X` is a filter, not a location; an empty
`namespace:` names nothing) is scoped by the base.

Three edges keep that honest:

- **A location must bound the WHOLE query.** `namespace:<base>/* OR nodeType:Markdown` names a
location under the base in its left branch only; the right branch would read everywhere, so the
query is refused. The test is the sufficiency predicate itself (an anchor, or a namespace filter
on every `OR` branch), with `partitions:all` set aside.
- **A multi-value exclusion is a filter too.** The parser emits `-namespace:A|B` (and
`-namespace:(A OR B)`) as a `namespace NotIn (…)` filter and leaves the anchor and scope alone; it
used to defer it with the positive alternation, which cleared the anchor — so the composed query
lost `namespace:<base>` and was refused as unanchored, or answered from outside the base.
- **The rest of the text is kept verbatim.** Dropping an empty `namespace:` is quote-aware: a
split-and-rejoin collapsed the spaces in `name:"Jane Q Example"` and dropped a `namespace:`
written inside a quoted value.

Before #6828 the tool deleted every `namespace:` from the text before prefixing the base, so
`namespace:<space B> name:"…"` under `basePath: @<space A>` became the FREE-TEXT term `<space B>`
read inside A — a semantic search flooded with whatever mentioned the words, reporting
`coverage.partitions: ["<space A>"]` for a query that named B — and `-namespace:X` became a search
FOR X. The coverage was honest about where it read; the composition was wrong about where to read.

## Reading a zero — how to state the denominator

The pre-deploy NodeType sweep is now written in the declared form, and its zero is stated against
Expand Down
23 changes: 18 additions & 5 deletions src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,6 @@ private string ParseSingleValue(string input, ref int i, bool stopAtParens, out
// namespaces when scope:selfAndAncestors. This is what lets a SINGLE query union
// built-in + per-context + per-NodeType + per-user instances (see AgentPickerProjection).
string[]? namespaceAlternation = null;
bool namespaceAlternationNegated = false;
// Where each wildcard `namespace:*/X` LIKE filter landed in filterTokens, so the post-loop
// pass can widen it to the SUBTREE once `scope:` is known (the qualifier may appear AFTER
// the namespace token). See WidenWildcardNamespacesToSubtree.
Expand Down Expand Up @@ -609,11 +608,25 @@ or QueryOperator.NotEqual
// Alternation `namespace:A|B|C` (parsed to In/NotIn with >1 values) is an EXACT
// MEMBERSHIP constraint across the listed namespaces, not a single base namespace
// + scope walk. Deferred to post-loop resolution (a `namespace IN (...)` filter).
if (token.Condition!.Operator is QueryOperator.In or QueryOperator.NotIn
&& token.Condition.Values.Length > 1)
//
// 🚨 A NEGATED alternation `-namespace:A|B` is an EXCLUSION FILTER, never the
// namespace constraint (#6828, review of #6837): deferring it with the positive
// form cleared the query's anchor and reset its scope in the post-loop pass, so
// `namespace:X -namespace:X/A|X/B` lost `X` — refused as unanchored, or, with
// `partitions:all`, answered from outside `X`. It is emitted here as the
// `namespace NotIn (...)` filter the deferred pass would have produced, and
// leaves the anchor and scope alone, like the single `-namespace:X` below.
if (token.Condition!.Operator is QueryOperator.NotIn && token.Condition.Values.Length > 1)
{
filterTokens.Add(new Token(TokenType.Comparison, string.Empty,
new QueryCondition("namespace", QueryOperator.NotIn,
token.Condition.Values.Select(v => v.Trim('/'))
.Distinct(StringComparer.OrdinalIgnoreCase).ToArray())));
continue;
}
if (token.Condition.Operator is QueryOperator.In && token.Condition.Values.Length > 1)
{
namespaceAlternation = token.Condition.Values;
namespaceAlternationNegated = token.Condition.Operator == QueryOperator.NotIn;
namespaceUsed = true;
continue;
}
Expand Down Expand Up @@ -813,7 +826,7 @@ or QueryOperator.NotEqual
.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
filterTokens.Add(new Token(TokenType.Comparison, string.Empty,
new QueryCondition("namespace",
namespaceAlternationNegated ? QueryOperator.NotIn : QueryOperator.In, distinct)));
QueryOperator.In, distinct)));
path = null;
scope = QueryScope.Exact;
}
Expand Down
129 changes: 121 additions & 8 deletions src/MeshWeaver.Mesh.Operations/MeshOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1450,14 +1450,67 @@ public IObservable<string> Search(string query, string? basePath = null, int lim
}
else
{
var cleanQuery = query.Replace("namespace:", "").Trim();
// "Base path to search FROM" is the whole subtree, not one level of it. A bare
// `namespace:X` is IMMEDIATE CHILDREN, so `basePath: @Admin` + `nodeType:LogIncident`
// answered 0 for nodes at `Admin/_LogIncident/…` — the parameter whose documented
// purpose is scoping did not reach what it scoped to (#4274, property 1). A query that
// states its own scope keeps it.
var scoped = StatesScope(cleanQuery) ? cleanQuery : $"{cleanQuery} scope:descendants".Trim();
fullQuery = $"namespace:{resolvedBase} {scoped}".Trim();
// 🚨 The query's OWN location is never rewritten (#6828). This used to strip every
// `namespace:` out of the text before prefixing the base, so `namespace:acme/Deals …`
// under `basePath: @beta` became the FREE-TEXT term `acme/Deals`, read against beta's
// partition: a semantic search flooded with whatever mentioned the words, with
// `coverage.partitions: ["beta"]` for a query that named acme — and `-namespace:X`, an
// exclusion, became a search FOR X. Now a query that states its own location keeps it
// when that location lies under the base (it is the narrower statement) and is
// refused, naming both, when it does not; only a query that names no location of its
// own is scoped by the base.
var own = new QueryParser().Parse(query);
var ownLocations = OwnLocations(own);
if (ownLocations is null)
{
// "Base path to search FROM" is the whole subtree, not one level of it. A bare
// `namespace:X` is IMMEDIATE CHILDREN, so `basePath: @Admin` + `nodeType:LogIncident`
// answered 0 for nodes at `Admin/_LogIncident/…` — the parameter whose documented
// purpose is scoping did not reach what it scoped to (#4274, property 1). A query that
// states its own scope keeps it.
// An EMPTY `namespace:`/`path:` names no location (callers write `namespace:` with a
// basePath to mean "the children of the base") — drop it, or it would re-anchor the
// composed query at the root.
// Quote-aware, and the rest of the text verbatim: a split-and-rejoin collapsed the
// spaces inside `name:"Jane Q Example"` and dropped a `namespace:` written INSIDE a
// quoted value, so an exact-name search ran on a different value.
var trimmed = WithoutEmptyLocationQualifiers(query);
var scoped = StatesScope(trimmed) ? trimmed : $"{trimmed} scope:descendants".Trim();
fullQuery = $"namespace:{resolvedBase} {scoped}".Trim();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the root cause was in the parser, so it is fixed there in bb6b277: QueryParser now emits a NEGATED multi-value namespace (-namespace:A|B, -namespace:(A OR B)) directly as a namespace NotIn (...) filter and leaves the anchor and scope alone, the same way the single -namespace:X arm already did. Only the positive alternation is deferred and clears the path. This also fixes a bare namespace:X -namespace:X/A|X/B query outside the tool. Regression AMultiValueExclusionUnderABaseKeepsTheBase covers both list spellings (expects only the non-archived node, coverage [BaseBeta]). Both fail without the fix. Hosting.Test and Graph.Test query/namespace suites still pass (210 + 74).

}
else
{
var outside = ownLocations.Where(l => !IsAtOrUnder(l, resolvedBase)).ToList();
if (outside.Count > 0)
{
logger.LogInformation(
"Search refused: query {Query} names locations {Outside} outside basePath {BasePath}",
query, outside, basePath);
return Observable.Return(
$"Error: basePath '{resolvedBase}' and the query's own location "
+ $"({string.Join(", ", outside.Select(l => l.Length == 0 ? "the root" : $"'{l}'"))}) disagree — "
+ "the query names a place outside the base, so no answer could honour both. "
+ "Drop the basePath to search where the query says, or drop the query's "
+ "path:/namespace: to search under the base.");
}
// Every named location lies under the base — but a location named in ONE `OR`
// branch bounds only that branch: `namespace:Base/* OR nodeType:Markdown` would run
// its right branch everywhere. The query keeps its own location only when the WHOLE
// expression is bounded by it (an anchor, or a namespace filter on every branch —
// the sufficiency predicate with the `partitions:all` flag set aside).
if (!(own with { CrossPartition = false }).IsSufficientlySpecified())
{
logger.LogInformation(
"Search refused: query {Query} names a location in only some of its branches under basePath {BasePath}",
query, basePath);
return Observable.Return(
$"Error: the query names a location in only some of its OR branches, so under basePath "
+ $"'{resolvedBase}' the other branches would search outside it. Give every branch a "
+ "path:/namespace: under the base, drop the query's own path:/namespace: to search the "
+ "whole base, or drop the basePath.");
}
fullQuery = query;
}
}

var parsed = new QueryParser().Parse(fullQuery);
Expand Down Expand Up @@ -1547,6 +1600,66 @@ private static bool StatesScope(string query)
=> query.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(token => token.StartsWith("scope:", StringComparison.OrdinalIgnoreCase));

/// <summary>
/// The locations a query names on its own — its <c>path:</c>/<c>namespace:</c> anchor(s) and
/// any positive namespace filter (membership or wildcard) — or <see langword="null"/> when it
/// names none and a <c>basePath</c> must scope it. An exclusion (<c>-namespace:X</c>) is a
/// filter, not a location, and is left for the base to scope (#6828).
/// </summary>
private static IReadOnlyList<string>? OwnLocations(ParsedQuery parsed)
{
var locations = new List<string>();
if (parsed.Paths is { Count: > 0 } paths)
locations.AddRange(paths);
else if (!string.IsNullOrEmpty(parsed.Path))
locations.Add(parsed.Path);
locations.AddRange(parsed.ExtractNamespacePatterns());
return locations.Count == 0 ? null : locations.Select(l => l.Trim('/')).ToList();
Comment thread
Copilot marked this conversation as resolved.
}

/// <summary>
/// <paramref name="query"/> without its EMPTY <c>namespace:</c>/<c>path:</c> tokens (callers'
/// "the base's children" idiom, which names no location), the rest of the text verbatim. Quote
/// aware: whitespace and a <c>namespace:</c> inside a quoted value belong to the value.
/// </summary>
private static string WithoutEmptyLocationQualifiers(string query)
{
var result = new System.Text.StringBuilder(query.Length);
var i = 0;
while (i < query.Length)
{
if (char.IsWhiteSpace(query[i]))
{
result.Append(query[i++]);
continue;
}
var start = i;
var quoted = false;
while (i < query.Length && (quoted || !char.IsWhiteSpace(query[i])))
{
if (quoted && query[i] == '\\' && i + 1 < query.Length)
i++;
else if (query[i] == '"')
quoted = !quoted;
Comment on lines +1642 to +1643
i++;
}
var token = query[start..i];
if (!token.Equals("namespace:", StringComparison.OrdinalIgnoreCase)
&& !token.Equals("path:", StringComparison.OrdinalIgnoreCase))
result.Append(token);
Comment on lines +1647 to +1649
}
return result.ToString().Trim();
}

/// <summary>Whether <paramref name="location"/> is <paramref name="basePath"/> or lies below it.</summary>
private static bool IsAtOrUnder(string location, string basePath)
{
var root = basePath.Trim('/');
return root.Length == 0
|| location.Equals(root, StringComparison.OrdinalIgnoreCase)
|| location.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// The coverage a <see cref="Search"/> can state for <paramref name="parsed"/> from the query
/// alone, or <see langword="null"/> when the query is unanchored and must be refused.
Expand Down
Loading
Loading