Validator reads schemas: schema checks, references and kinds from mod schemas, option and when conditions checked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df702b0390
commit
1ae0b79d1b
6 files changed
+311
-120
No files matched your search
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
"description": "Static validation of mods without running the game: manifest schema, entity strategies (characters, items, quests, skills, recipes, actions), cross references, depends, scenario commands and scene patches, with an optional text report.",
|
"description": "Static validation of mods without running the game: manifest schema, entity strategies (characters, items, quests, skills, recipes, actions), cross references, depends, scenario commands and scene patches, with an optional text report.",
|
||||||
"category": "debug",
|
"category": "debug",
|
||||||
"sources": ["src/**/*.cs"],
|
"sources": ["src/**/*.cs"],
|
||||||
"depends": ["dialog_base>=1", "interactive_base>=1"]
|
"depends": ["dialog_base>=1", "interactive_base>=1", "schema_base>=1"]
|
||||||
}
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace Cthangover.Validator
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Declarative map "scenario action → named argument → <see cref="ValidationKind"/>"
|
|
||||||
/// for actions whose arguments reference declared content. The runtime
|
|
||||||
/// <c>IScenarioAction</c> contract carries no parameter metadata, so the
|
|
||||||
/// validator keeps the knowledge here instead: each entry names one action
|
|
||||||
/// argument and the entity kind it must resolve to. Scenario validation adds
|
|
||||||
/// a graph reference per literal value (see
|
|
||||||
/// <see cref="ScenarioCommandValidationStrategy"/>), and
|
|
||||||
/// <see cref="ReferenceStrategy"/> reports a missing declaration with the
|
|
||||||
/// exact scenario file and line — the static answer to the typo class the
|
|
||||||
/// runtime factories can only log.
|
|
||||||
/// <para/>
|
|
||||||
/// The table intentionally covers only actions with literal content ids
|
|
||||||
/// today (party.add / character.send_notification /
|
|
||||||
/// character.add_skill / battle.init). Extend it when a new action starts
|
|
||||||
/// accepting content references; the mechanics need no code changes.
|
|
||||||
/// Comma-separated lists (battle.init enemies=) and runtime-computed values
|
|
||||||
/// (the ${name} marker) are supported by the caller, not here.
|
|
||||||
/// </summary>
|
|
||||||
public static class ActionArgumentReferences
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<string, string> CharacterType = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["type"] = ValidationKind.Character,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> PartyAdd = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["id"] = ValidationKind.Character,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> BattleInit = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["scene"] = ValidationKind.Scene,
|
|
||||||
["enemies"] = ValidationKind.Character,
|
|
||||||
["quest_id"] = ValidationKind.Quest,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> AddSkill = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["id"] = ValidationKind.Skill,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, IReadOnlyDictionary<string, string>> Table =
|
|
||||||
new Dictionary<string, IReadOnlyDictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["party.add"] = PartyAdd,
|
|
||||||
["character.send_notification"] = CharacterType,
|
|
||||||
["character.add_skill"] = AddSkill,
|
|
||||||
["battle.init"] = BattleInit,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the argument→kind map for an action, or false when the action
|
|
||||||
/// carries no known content references. Lookup is case-insensitive to
|
|
||||||
/// match the reference check for the action name itself.
|
|
||||||
/// </summary>
|
|
||||||
public static bool TryGetKinds(string actionName, out IReadOnlyDictionary<string, string> kinds)
|
|
||||||
{
|
|
||||||
return Table.TryGetValue(actionName, out kinds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,6 +92,9 @@ namespace Cthangover.Validator
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var strategy = EntityKindStrategyRegistry.Resolve(kind);
|
var strategy = EntityKindStrategyRegistry.Resolve(kind);
|
||||||
|
// A kind some mod describes in a schema is known without a strategy of its own.
|
||||||
|
if (strategy == null && ValidationSchemas.Of(context).Kinds.ContainsKey(kind))
|
||||||
|
continue;
|
||||||
if (strategy == null)
|
if (strategy == null)
|
||||||
{
|
{
|
||||||
context.AddWarning("UNKNOWN_ENTITY_KIND",
|
context.AddWarning("UNKNOWN_ENTITY_KIND",
|
||||||
|
|||||||
@@ -25,15 +25,16 @@ namespace Cthangover.Validator
|
|||||||
/// <item>references collected by <see cref="ICommandReferenceMetadata"/> are added to the
|
/// <item>references collected by <see cref="ICommandReferenceMetadata"/> are added to the
|
||||||
/// shared graph (backgrounds, scenes, music, sounds, effects, avatars) and checked by
|
/// shared graph (backgrounds, scenes, music, sounds, effects, avatars) and checked by
|
||||||
/// <see cref="ReferenceStrategy"/>; scenario actions are checked against the action registry.</item>
|
/// <see cref="ReferenceStrategy"/>; scenario actions are checked against the action registry.</item>
|
||||||
/// <item>content references inside action arguments (<see cref="ActionArgumentReferences"/>:
|
/// <item>content references inside action arguments (character ids, skills, quest ids,
|
||||||
/// character ids, skills, quest ids, battle enemy lists) are added to the same graph, so a
|
/// battle enemy lists) come from the actions' schemas (<see cref="SchemaValidationStrategy"/>)
|
||||||
/// typo is reported against the declaring mods with file and line.</item>
|
/// and go into the same graph, so a typo is reported against the declaring mods with file
|
||||||
|
/// and line.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScenarioCommandValidationStrategy : IValidationStrategy
|
public class ScenarioCommandValidationStrategy : IValidationStrategy
|
||||||
{
|
{
|
||||||
private static readonly string[] SelectAllowedNamed = { "key", "first", "second" };
|
private static readonly string[] SelectAllowedNamed = { "key", "first", "second" };
|
||||||
private static readonly string[] OptionAllowedNamed = { "key", "goto" };
|
private static readonly string[] OptionAllowedNamed = { "key", "goto", "if" };
|
||||||
|
|
||||||
private static readonly object actionLock = new();
|
private static readonly object actionLock = new();
|
||||||
private static HashSet<string> actionNames;
|
private static HashSet<string> actionNames;
|
||||||
@@ -144,6 +145,8 @@ namespace Cthangover.Validator
|
|||||||
|
|
||||||
CheckEmptyNamed(context, mod, file, lineNumber, command, named);
|
CheckEmptyNamed(context, mod, file, lineNumber, command, named);
|
||||||
CheckAllowedNamed(context, mod, file, lineNumber, command, named, OptionAllowedNamed);
|
CheckAllowedNamed(context, mod, file, lineNumber, command, named, OptionAllowedNamed);
|
||||||
|
if (named.TryGetValue("if", out var optionCondition))
|
||||||
|
CheckCondition(context, mod, file, lineNumber, command, optionCondition);
|
||||||
|
|
||||||
var target = arrowTarget;
|
var target = arrowTarget;
|
||||||
if (string.IsNullOrEmpty(target) && named.TryGetValue("goto", out var goTo))
|
if (string.IsNullOrEmpty(target) && named.TryGetValue("goto", out var goTo))
|
||||||
@@ -167,6 +170,9 @@ namespace Cthangover.Validator
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command == "when")
|
||||||
|
CheckCondition(context, mod, file, lineNumber, command, string.Join(" ", positional));
|
||||||
|
|
||||||
var spec = strategy as IScenarioCommandSpec;
|
var spec = strategy as IScenarioCommandSpec;
|
||||||
if (spec != null)
|
if (spec != null)
|
||||||
CheckSpec(context, mod, file, lineNumber, command, positional, named, arrowTarget, spec, labels);
|
CheckSpec(context, mod, file, lineNumber, command, positional, named, arrowTarget, spec, labels);
|
||||||
@@ -190,13 +196,34 @@ namespace Cthangover.Validator
|
|||||||
AddReference(context, mod, file, lineNumber, referenceMetadata.Positional0Kind, positional[0]);
|
AddReference(context, mod, file, lineNumber, referenceMetadata.Positional0Kind, positional[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command == "action" && positional.Count > 0)
|
|
||||||
AddActionArgumentReferences(context, mod, file, lineNumber, positional[0], named);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions);
|
FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a condition of an <c>option if=</c> or a <c>when</c> line with the game's own
|
||||||
|
/// condition parser (every leaf parser the loaded mods registered), so a broken
|
||||||
|
/// condition is an error here and not a silently hidden option at play time.
|
||||||
|
/// </summary>
|
||||||
|
private static void CheckCondition(ValidationContext context, IModInfo mod, string file, int line, string command, string condition)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(condition))
|
||||||
|
{
|
||||||
|
context.AddError("SCENARIO_EMPTY_ARGUMENT", $"'{command}' needs a condition", mod.Id, file, line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ConditionParser.Parse(condition);
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
context.AddError("SCENARIO_BAD_CONDITION", $"'{command}': {ex.Message.Split('\n')[0]}", mod.Id, file, line,
|
||||||
|
"quote a condition with spaces whole (\"if=a && b\") and check names and parentheses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static HashSet<string> CollectLabels(ValidationContext context, IModInfo mod, string file, string[] lines, int lineOffset)
|
private static HashSet<string> CollectLabels(ValidationContext context, IModInfo mod, string file, string[] lines, int lineOffset)
|
||||||
{
|
{
|
||||||
var labels = new HashSet<string>(StringComparer.Ordinal);
|
var labels = new HashSet<string>(StringComparer.Ordinal);
|
||||||
@@ -391,52 +418,6 @@ namespace Cthangover.Validator
|
|||||||
context.Graph.AddReference(ValidationKind.Avatar, value, mod.Id, file, line);
|
context.Graph.AddReference(ValidationKind.Avatar, value, mod.Id, file, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Collects content references hidden in the named arguments of an
|
|
||||||
/// <c>action</c> command. The scenario DSL treats action arguments as
|
|
||||||
/// opaque dialog variables, so without this pass a typo like
|
|
||||||
/// <c>action party.add id=Murakamiy</c> would only surface
|
|
||||||
/// at runtime as a factory error log. Values from
|
|
||||||
/// <see cref="ActionArgumentReferences"/> become graph references and are
|
|
||||||
/// reconciled by <see cref="ReferenceStrategy"/>, which reports the
|
|
||||||
/// offending scenario file and line. Comma-separated values (e.g.
|
|
||||||
/// battle.init enemies=) produce one reference per entry; values with the
|
|
||||||
/// ${name} marker are computed at play time and cannot be resolved
|
|
||||||
/// statically, so they are skipped instead of misreported.
|
|
||||||
/// </summary>
|
|
||||||
private static void AddActionArgumentReferences(
|
|
||||||
ValidationContext context,
|
|
||||||
IModInfo mod,
|
|
||||||
string file,
|
|
||||||
int line,
|
|
||||||
string actionName,
|
|
||||||
Dictionary<string, string> named)
|
|
||||||
{
|
|
||||||
if (!ActionArgumentReferences.TryGetKinds(actionName, out var kinds))
|
|
||||||
return;
|
|
||||||
|
|
||||||
foreach (var pair in kinds)
|
|
||||||
{
|
|
||||||
if (!named.TryGetValue(pair.Key, out var value) || string.IsNullOrWhiteSpace(value))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (value.Contains("$" + "{"))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (var part in value.Split(','))
|
|
||||||
{
|
|
||||||
var target = part.Trim();
|
|
||||||
if (target.Length == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (pair.Value == ValidationKind.Scene && SceneManager.IsNativeSceneName(target))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
context.Graph.AddReference(pair.Value, target, mod.Id, file, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddReference(ValidationContext context, IModInfo mod, string file, int line, PositionalReferenceKind kind, string target)
|
private static void AddReference(ValidationContext context, IModInfo mod, string file, int line, PositionalReferenceKind kind, string target)
|
||||||
{
|
{
|
||||||
switch (kind)
|
switch (kind)
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
namespace Cthangover.Validator
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Mods.Dialog.Scenarios;
|
||||||
|
using Cthangover.Mods.Schemas;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The validator's reading of the schemas every mod ships (<c>schemas/*.json</c>, merged
|
||||||
|
/// by <see cref="SchemaCatalog"/>). It replaced the table of action arguments the
|
||||||
|
/// validator kept in code — four actions, one of them with a wrong parameter name —
|
||||||
|
/// with descriptions that live next to the actions and that any mod may add:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>action</c> lines: unknown and missing parameters, bad numbers and choices, and
|
||||||
|
/// graph references for every entity a parameter names (characters, quests, skills,
|
||||||
|
/// scenes, items…), which <see cref="ReferenceStrategy"/> reports when undeclared;</item>
|
||||||
|
/// <item>other described commands: positional values and their references (a check's stat);</item>
|
||||||
|
/// <item>entities of described kinds: references in fields, in list elements (a trait's
|
||||||
|
/// <c>Stat</c>) and in map keys (a character's <c>Stats</c>).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Kinds no other strategy declares (shops, stats, archetypes, equipment slots…) are
|
||||||
|
/// declared here from the mods' data, so their references can be checked and the
|
||||||
|
/// resource atlas sees their owners and patches. References are added only for kinds
|
||||||
|
/// that are declared at all; a kind nothing in the graph knows (a map, a tileset) is
|
||||||
|
/// not judged. Like every strategy it reads the mods' files, never the running game.
|
||||||
|
/// </summary>
|
||||||
|
public class SchemaValidationStrategy : IValidationStrategy
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Id => "schemas";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValidationPhase Phase => ValidationPhase.Contracts;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Order => 900;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValidationFeature Feature => ValidationFeature.Json;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Validate(ValidationContext context)
|
||||||
|
{
|
||||||
|
var catalog = ValidationSchemas.Of(context);
|
||||||
|
foreach (var error in catalog.Errors)
|
||||||
|
context.AddError("SCHEMA_PARSE_ERROR", error, error.Split(':')[0]);
|
||||||
|
|
||||||
|
// Every data file is parsed once: for the declarations and for the field checks.
|
||||||
|
var entities = context.Mods.Where(m => m.FileProvider != null).ToDictionary(m => m, m => Entities(m).ToList());
|
||||||
|
var ownKinds = DeclareDescribedKinds(context, catalog, entities);
|
||||||
|
var declared = new HashSet<string>(context.Graph.Declarations.Select(d => d.Kind), StringComparer.OrdinalIgnoreCase) { ValidationKind.Scene };
|
||||||
|
|
||||||
|
foreach (var (mod, list) in entities)
|
||||||
|
{
|
||||||
|
context.ThrowIfCancellationRequested();
|
||||||
|
foreach (var file in ModFileReader.ListFiles(mod.FileProvider, "scenarios", true).Where(f => f.EndsWith(".scenario", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
CheckScenario(context, catalog, mod, file, declared, ownKinds);
|
||||||
|
foreach (var (file, kind, entity) in list)
|
||||||
|
{
|
||||||
|
if (catalog.Kinds.TryGetValue(kind, out var schema))
|
||||||
|
CheckEntity(context, mod, file, schema, entity, ownKinds.Contains(kind) ? declared : ownKinds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> DeclareDescribedKinds(ValidationContext context, SchemaCatalog catalog, Dictionary<IModInfo, List<(string File, string Kind, JsonElement Entity)>> entities)
|
||||||
|
{
|
||||||
|
var known = new HashSet<string>(context.Graph.Declarations.Select(d => d.Kind), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var own = new HashSet<string>(catalog.Kinds.Keys.Where(k => !known.Contains(k)), StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var (mod, list) in entities)
|
||||||
|
{
|
||||||
|
foreach (var (file, kind, entity) in list)
|
||||||
|
{
|
||||||
|
if (own.Contains(kind) && entity.TryGetProperty("Id", out var id))
|
||||||
|
context.Graph.AddDeclaration(kind, id.ToString(), mod.Id, file, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return own;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CheckScenario(ValidationContext context, SchemaCatalog catalog, IModInfo mod, string file, HashSet<string> declared, HashSet<string> ownKinds)
|
||||||
|
{
|
||||||
|
string text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = ModFileReader.ReadResolved(mod.FileProvider, file);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text == null)
|
||||||
|
return;
|
||||||
|
var body = ScenarioParser.ParseMetadata(text).remainingText.Split('\n');
|
||||||
|
var offset = text.Split('\n').Length - body.Length;
|
||||||
|
for (var i = 0; i < body.Length; i++)
|
||||||
|
{
|
||||||
|
var line = body[i].Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith(':'))
|
||||||
|
continue;
|
||||||
|
var positional = new List<string>();
|
||||||
|
var named = new Dictionary<string, string>();
|
||||||
|
var command = ScenarioParser.SplitLine(line, positional, named, out _);
|
||||||
|
var lineNumber = offset + i + 1;
|
||||||
|
if (command == "action" && positional.Count > 0 && catalog.Actions.TryGetValue(positional[0], out var action))
|
||||||
|
{
|
||||||
|
Report(context, SchemaChecker.CheckNamed(action.Id, action.Params, named, null), mod.Id, file, lineNumber);
|
||||||
|
foreach (var field in action.Params ?? new List<FieldSchema>())
|
||||||
|
{
|
||||||
|
var value = named.FirstOrDefault(p => string.Equals(p.Key, field.Key, StringComparison.OrdinalIgnoreCase)).Value;
|
||||||
|
AddReferences(context, field, value, declared, mod.Id, file, lineNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (command != null && command != "action" && catalog.Commands.TryGetValue(command, out var schema))
|
||||||
|
{
|
||||||
|
Report(context, SchemaChecker.CheckPositional(command, schema.Positional, positional, null), mod.Id, file, lineNumber);
|
||||||
|
// The commands' own strategies already reference their scenes and resources;
|
||||||
|
// only kinds the schemas brought in are referenced here.
|
||||||
|
var fields = schema.Positional ?? new List<FieldSchema>();
|
||||||
|
for (var p = 0; p < fields.Count && p < positional.Count; p++)
|
||||||
|
AddReferences(context, fields[p], positional[p], ownKinds, mod.Id, file, lineNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CheckEntity(ValidationContext context, IModInfo mod, string file, KindSchema schema, JsonElement entity, HashSet<string> targets)
|
||||||
|
{
|
||||||
|
var id = entity.TryGetProperty("Id", out var idProp) ? idProp.ToString() : "?";
|
||||||
|
var owner = $"{schema.Kind} '{id}'";
|
||||||
|
foreach (var field in schema.Fields)
|
||||||
|
{
|
||||||
|
if (!TryGet(entity, field.Key, out var value))
|
||||||
|
continue;
|
||||||
|
switch (field.Type?.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "list" when value.ValueKind == JsonValueKind.Array && field.Of != null:
|
||||||
|
foreach (var element in value.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.Object))
|
||||||
|
{
|
||||||
|
foreach (var sub in field.Of)
|
||||||
|
{
|
||||||
|
if (!TryGet(element, sub.Key, out var subValue))
|
||||||
|
continue;
|
||||||
|
Report(context, SchemaChecker.CheckValue($"{owner}.{field.Key}", sub, Text(subValue), null), mod.Id, file, 0);
|
||||||
|
AddReferences(context, sub, Text(subValue), targets, mod.Id, file, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "map" when value.ValueKind == JsonValueKind.Object && targets.Contains(field.Kind ?? string.Empty):
|
||||||
|
foreach (var key in value.EnumerateObject().Select(p => p.Name))
|
||||||
|
context.Graph.AddReference(field.Kind, key, mod.Id, file, 0);
|
||||||
|
break;
|
||||||
|
case "ref_list" when value.ValueKind == JsonValueKind.Array:
|
||||||
|
AddReferences(context, field, string.Join(",", value.EnumerateArray().Select(Text)), targets, mod.Id, file, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
|
||||||
|
{
|
||||||
|
Report(context, SchemaChecker.CheckValue(owner, field, Text(value), null), mod.Id, file, 0);
|
||||||
|
AddReferences(context, field, Text(value), targets, mod.Id, file, 0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddReferences(ValidationContext context, FieldSchema field, string value, HashSet<string> kinds, string mod, string file, int line)
|
||||||
|
{
|
||||||
|
foreach (var (kind, id) in SchemaReferences.Of(field, value))
|
||||||
|
{
|
||||||
|
if (!kinds.Contains(kind))
|
||||||
|
continue;
|
||||||
|
if (kind == ValidationKind.Scene && SceneManager.IsNativeSceneName(id))
|
||||||
|
continue;
|
||||||
|
context.Graph.AddReference(kind, id, mod, file, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<(string File, string Kind, JsonElement Entity)> Entities(IModInfo mod)
|
||||||
|
{
|
||||||
|
foreach (var file in ModFileReader.ListFiles(mod.FileProvider, string.Empty, true).Where(IsDataFile))
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = JsonDocument.Parse(ModFileReader.ReadResolved(mod.FileProvider, file) ?? string.Empty);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
using (doc)
|
||||||
|
{
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("Kind", out var kind) || kind.ValueKind != JsonValueKind.String
|
||||||
|
|| !root.TryGetProperty("Items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||||
|
continue;
|
||||||
|
foreach (var entity in items.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.Object).ToList())
|
||||||
|
yield return (file, kind.GetString(), entity.Clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDataFile(string path)
|
||||||
|
=> path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.StartsWith("schemas/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.StartsWith("patches/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.EndsWith("manifest.json", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool TryGet(JsonElement obj, string key, out JsonElement value)
|
||||||
|
{
|
||||||
|
foreach (var prop in obj.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = prop.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
|
||||||
|
|
||||||
|
private static void Report(ValidationContext context, IEnumerable<SchemaIssue> issues, string mod, string file, int line)
|
||||||
|
{
|
||||||
|
foreach (var issue in issues)
|
||||||
|
{
|
||||||
|
if (issue.IsError)
|
||||||
|
context.AddError(issue.Code, issue.Message, mod, file, line);
|
||||||
|
else
|
||||||
|
context.AddWarning(issue.Code, issue.Message, mod, file, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Cthangover.Validator
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Cthangover.Mods.Schemas;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The schemas of the validated mods (their <c>schemas/*.json</c>, read from the providers
|
||||||
|
/// in the context's mod order), built once per validation run and shared by the
|
||||||
|
/// strategies that need them: the entity strategy treats a described kind as known, the
|
||||||
|
/// schema strategy checks content against it.
|
||||||
|
/// </summary>
|
||||||
|
public static class ValidationSchemas
|
||||||
|
{
|
||||||
|
private static readonly ConditionalWeakTable<ValidationContext, SchemaCatalog> Catalogs = new();
|
||||||
|
|
||||||
|
/// <summary>The merged catalog of <paramref name="context"/>'s mods.</summary>
|
||||||
|
public static SchemaCatalog Of(ValidationContext context)
|
||||||
|
=> Catalogs.GetValue(context, Build);
|
||||||
|
|
||||||
|
private static SchemaCatalog Build(ValidationContext context) => SchemaCatalog.Build(Files(context));
|
||||||
|
|
||||||
|
private static IEnumerable<(string, string, string)> Files(ValidationContext context)
|
||||||
|
{
|
||||||
|
foreach (var mod in context.Mods.Where(m => m.FileProvider != null))
|
||||||
|
{
|
||||||
|
foreach (var file in ModFileReader.ListFiles(mod.FileProvider, "schemas", true).Where(f => f.EndsWith(".json", StringComparison.OrdinalIgnoreCase)).OrderBy(f => f, StringComparer.Ordinal))
|
||||||
|
yield return (mod.Id, file, ModFileReader.ReadResolved(mod.FileProvider, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user