fix(m9/T32b): resolve $ref in InboundAPI runtime validators (no deploy-passes/runtime-400); diamond test; ref-annotation message

This commit is contained in:
Joseph Doherty
2026-06-18 12:16:39 -04:00
parent 26e2cdef23
commit 71d5722692
9 changed files with 424 additions and 25 deletions
@@ -104,10 +104,10 @@ public sealed class InboundApiSchema
public static InboundApiSchema? Parse(string? json, Func<string, string?>? resolveRef)
{
var result = ParseWithRefs(json, resolveRef);
if (result.UnresolvedRefs.Count > 0)
if (result.UnresolvedReferences.Count > 0)
{
throw new JsonException(
$"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedRefs)}.");
$"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedReferences.Select(r => r.Describe()))}.");
}
return result.Schema;
@@ -141,7 +141,7 @@ public sealed class InboundApiSchema
return new SchemaParseResult(null, []);
}
var unresolved = new List<string>();
var unresolved = new List<UnresolvedSchemaRef>();
// The active-ref set tracks the refs being resolved on the CURRENT path so a
// cycle (A→B→A) is detected and reported instead of recursing forever.
var ctx = new RefResolutionContext(resolveRef, unresolved, new HashSet<string>(StringComparer.Ordinal));
@@ -166,7 +166,7 @@ public sealed class InboundApiSchema
/// </summary>
private sealed record RefResolutionContext(
Func<string, string?>? Resolver,
List<string> Unresolved,
List<UnresolvedSchemaRef> Unresolved,
HashSet<string> ActiveRefs);
private static InboundApiSchema ParseSchema(JsonElement el, int depth, RefResolutionContext ctx)
@@ -242,6 +242,22 @@ public sealed class InboundApiSchema
/// <summary>The placeholder type for an unresolvable <c>$ref</c> node (dangling, cyclic, or over-depth).</summary>
private const string UnresolvedRefType = "ref";
/// <summary>
/// M9-T32b — cheap pre-flight check: does this definition JSON contain ANY
/// <c>$ref</c> token at all? Lets a caller (e.g. the InboundAPI runtime path) skip the
/// shared-schema library pre-load entirely when a schema uses no references — so a
/// <c>$ref</c>-free method pays NO extra cost beyond today. The check is intentionally
/// conservative (a substring scan, not a parse): it may return <c>true</c> for a
/// schema whose only <c>$ref</c> is not a <c>lib:</c> reference, in which case the
/// subsequent <see cref="ParseWithRefs"/> simply never consults the resolver — correct,
/// just not maximally lazy. It never returns <c>false</c> when a real <c>lib:</c> ref
/// is present, so it is always safe to gate the pre-load on.
/// </summary>
/// <param name="json">The definition JSON to scan; <c>null</c>/empty yields <c>false</c>.</param>
/// <returns><c>true</c> when the JSON contains a <c>$ref</c> token and a resolver may be needed.</returns>
public static bool MightContainRef(string? json) =>
!string.IsNullOrEmpty(json) && json.Contains("$ref", StringComparison.Ordinal);
/// <summary>
/// Recognizes a <c>{"$ref":"lib:Name"}</c> reference node and extracts its target
/// name (the part after the <c>lib:</c> scheme prefix). Returns <c>false</c> for any
@@ -294,14 +310,14 @@ public sealed class InboundApiSchema
// ParseWithRefs path rather than aborting the whole parse with a throw.
if (depth >= MaxDepth)
{
ctx.Unresolved.Add($"{refName} (ref nesting exceeds depth {MaxDepth})");
ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, $"ref nesting exceeds depth {MaxDepth}"));
return new InboundApiSchema { Type = UnresolvedRefType };
}
// Cycle guard: this name is already being resolved on the current path.
if (ctx.ActiveRefs.Contains(refName))
{
ctx.Unresolved.Add($"{refName} (cyclic reference)");
ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, "cyclic reference"));
return new InboundApiSchema { Type = UnresolvedRefType };
}
@@ -309,7 +325,7 @@ public sealed class InboundApiSchema
if (string.IsNullOrWhiteSpace(referenced))
{
// Dangling: the seam can't resolve it (or no seam was supplied).
ctx.Unresolved.Add(refName);
ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, Reason: null));
return new InboundApiSchema { Type = UnresolvedRefType };
}
@@ -591,19 +607,64 @@ public sealed class InboundApiSchema
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
/// <summary>
/// A single <c>{"$ref":"lib:Name"}</c> reference that could NOT be resolved during
/// <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b). The reference <see cref="Name"/>
/// is kept SEPARATE from the diagnostic <see cref="Reason"/> so a message can render the
/// pointer cleanly (e.g. <c>schema 'lib:Foo' could not be resolved (cyclic reference)</c>)
/// rather than embedding the annotation inside the <c>lib:</c>-looking string.
/// </summary>
/// <param name="Name">
/// The library entry name (the part after the <c>lib:</c> scheme prefix) that could not be
/// resolved — never carries an annotation.
/// </param>
/// <param name="Reason">
/// The diagnostic reason for cyclic/over-depth cases (e.g. <c>"cyclic reference"</c> or
/// <c>"ref nesting exceeds depth 64"</c>), or <c>null</c> for a plain dangling reference
/// (the seam returned <c>null</c>, or no seam was supplied).
/// </param>
public sealed record UnresolvedSchemaRef(string Name, string? Reason)
{
/// <summary>
/// Renders this reference as the legacy single-string form (<c>Name</c> for a plain
/// dangling ref, <c>"Name (Reason)"</c> when annotated) — used to project the
/// backward-compatible <see cref="SchemaParseResult.UnresolvedRefs"/> list.
/// </summary>
/// <returns>The reference name, with the reason appended in parentheses when present.</returns>
public string ToLegacyString() => Reason is null ? Name : $"{Name} ({Reason})";
/// <summary>
/// Renders this reference for an end-user error message, keeping the <c>lib:</c>-qualified
/// pointer name separate from the parenthesised reason
/// (e.g. <c>lib:Foo (cyclic reference)</c> or just <c>lib:Foo</c>).
/// </summary>
/// <returns>The <c>lib:</c>-qualified reference, with the reason appended in parentheses when present.</returns>
public string Describe() => Reason is null ? $"lib:{Name}" : $"lib:{Name} ({Reason})";
}
/// <summary>
/// The outcome of <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b): the parsed
/// schema (with <c>{"$ref":"lib:Name"}</c> references resolved where possible) plus the
/// names of any references that could NOT be resolved — dangling (the seam returned
/// <c>null</c> or no seam was supplied), cyclic, or over-depth. A non-empty
/// <see cref="UnresolvedRefs"/> is the deploy-blocking signal the validation layer acts on.
/// references that could NOT be resolved — dangling (the seam returned <c>null</c> or no
/// seam was supplied), cyclic, or over-depth. A non-empty <see cref="UnresolvedReferences"/>
/// is the deploy-/runtime-blocking signal the validation layer acts on.
/// </summary>
/// <param name="Schema">The parsed schema, or <c>null</c> when the input was empty.</param>
/// <param name="UnresolvedRefs">
/// The reference targets that could not be resolved, each annotated with the reason for
/// cyclic/over-depth cases (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every
/// reference resolved.
/// <param name="UnresolvedReferences">
/// The structured reference targets that could not be resolved each carrying the bare
/// <see cref="UnresolvedSchemaRef.Name"/> separate from an optional
/// <see cref="UnresolvedSchemaRef.Reason"/>. Empty when every reference resolved.
/// </param>
public sealed record SchemaParseResult(
InboundApiSchema? Schema,
IReadOnlyList<string> UnresolvedRefs);
IReadOnlyList<UnresolvedSchemaRef> UnresolvedReferences)
{
/// <summary>
/// Backward-compatible flat view of <see cref="UnresolvedReferences"/>: each entry is the
/// reference name, with the reason appended in parentheses for cyclic/over-depth cases
/// (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every reference resolved. Prefer
/// <see cref="UnresolvedReferences"/> for new code that needs the name and reason apart.
/// </summary>
public IReadOnlyList<string> UnresolvedRefs =>
UnresolvedReferences.Select(r => r.ToLegacyString()).ToList();
}