{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MORPH-SPEC .morph/config/config.json",
  "description": "Describes ONLY project.tests[] — the plural declaration of the project's test suites. Everything else in config.json is deliberately left open (additionalProperties: true) so that configs already in the field never break on a key this schema has not heard of. The authority at runtime is validateTestNodes() in src/lib/tasks/test-nodes.js: a schema nothing calls is decoration.",
  "type": "object",
  "additionalProperties": true,
  "properties": {
    "project": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "tests": {
          "type": "array",
          "description": "One entry per test suite. Absent (not empty) means the four-step fallback of resolveTestPlan() decides — every project created before this key existed keeps working. An empty array is an explicit declaration of 'no test suites'.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["id", "command"],
            "properties": {
              "id": {
                "type": "string",
                "pattern": "^[a-z0-9][a-z0-9-]*$",
                "description": "Unique within the array. What `verify --filter` matches and the key of nodes.tests.runs[]. A duplicate id is a config error, not a silent tie."
              },
              "command": {
                "type": "string",
                "minLength": 1,
                "description": "Literal command, executed by the shell from `cwd`."
              },
              "cwd": {
                "type": "string",
                "minLength": 1,
                "default": ".",
                "description": "Relative to the project root. A path that resolves outside the root is refused: it addresses another tree."
              },
              "runner": {
                "type": "string",
                "enum": ["dotnet", "vitest", "jest", "node-test", "playwright", "none", "evals"],
                "description": "Chooses the test-count extractor and whether a prebuild applies. Inferred from `command` when omitted; `none` means the count is UNKNOWN, never zero. `evals` is the ONLY value that is never inferred - it is a declaration that this suite reads a committed response cache, and it REQUIRES the `evals` block below."
              },
              "expectedCount": {
                "type": "integer",
                "minimum": 0,
                "description": "Recorded baseline. Written ONLY by `morph-spec verify --record-baseline`, from a green run — never typed by hand."
              },
              "timeoutMs": {
                "type": "integer",
                "minimum": 1,
                "description": "Ceiling for this node. Exists because the derived default (120s x chained projects) is shaped for `dotnet test` and is hostile to a Node suite that takes twelve minutes."
              },
              "build": {
                "type": "array",
                "items": { "type": "string", "minLength": 1 },
                "description": "Test projects to compile before running (runner 'dotnet' only). Without it, `--no-build` in the command has no fresh binary behind it."
              },
              "e2e": {
                "type": "boolean",
                "default": false,
                "description": "Requires the application stack to be up. Runs at feature scope only, never in the per-task loop."
              },
              "linuxImage": {
                "type": "string",
                "minLength": 1,
                "description": "Overrides the container image used by `verify --linux` for this node."
              },
              "groups": {
                "type": "array",
                "minItems": 1,
                "uniqueItems": true,
                "items": { "type": "string", "enum": ["backend", "frontend", "tests", "infra", "docs"] },
                "description": "Which task group(s) (tasks.json `group`) this node verifies. Absent means the runner's own default applies (RUNNER_DEFAULT_GROUPS in src/lib/tasks/test-nodes.js) — `none`/`evals` have no default and are unscoped unless declared here."
              },
              "evals": {
                "type": "object",
                "additionalProperties": false,
                "required": ["cachePath"],
                "description": "Required by, and only valid for, runner evals. Declared on any other runner it would be a key the verifier silently ignores - the same fail-open shape as a typo in tests[].",
                "properties": {
                  "cachePath": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Root of the response-cache tree, relative to the project root, and COMMITTED. A path that resolves outside the root is refused, same rule as `cwd`. No default: a silent one would create a cache tree somewhere nobody declared."
                  },
                  "datasetPath": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Root of the versioned dataset, laid out as {agent}/{tenant}/{scenario}.json. Defaults to `eval-datasets` beside cachePath."
                  },
                  "envFile": {
                    "type": "string",
                    "minLength": 1,
                    "description": "GITIGNORED file holding the provider credential, read ONLY by --refresh-cache. Same pattern as e2e.auth.envFile; a credential never travels in the committed config. Defaults to `.env.evals`."
                  }
                }
              }
            }
          }
        },
        "evals": {
          "type": "object",
          "additionalProperties": true,
          "description": "Project-level eval settings, distinct from the per-node block above: this one describes the PROJECT (which paths count as prompt or model), not one suite.",
          "properties": {
            "watch": {
              "type": "array",
              "items": { "type": "string", "minLength": 1 },
              "description": "Globs that mean 'this is a prompt or a model'. Matched against what the feature changed (git diff plus untracked) to decide whether Gate 3 should pause for a missing eval. Absent means the project declares no such paths - reported as a reason of its own, distinct from 'declared and nothing was touched'."
            }
          }
        }
      }
    }
  }
}
