{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skill-map.ai/spec/v1/scan-result.schema.json",
  "title": "ScanResult",
  "description": "Canonical output of `sm scan --json` (and the data shape sent over WebSocket scan events). Self-describing and versioned; consumers MUST check `schemaVersion` before parsing.",
  "type": "object",
  "required": ["schemaVersion", "scannedAt", "roots", "nodes", "links", "issues", "stats"],
  "additionalProperties": false,
  "properties": {
    "schemaVersion": {
      "type": "integer",
      "const": 1,
      "description": "Scan result shape version. Bumped only on breaking changes to this schema."
    },
    "scannedAt": {
      "type": "integer",
      "description": "Unix milliseconds when the scan started."
    },
    "scannedBy": {
      "type": ["object", "null"],
      "description": "Implementation metadata. Optional so that synthetic fixtures can omit it.",
      "additionalProperties": false,
      "properties": {
        "name": { "type": "string", "description": "Implementation name (e.g. `skill-map`)." },
        "version": { "type": "string", "description": "Implementation semver." },
        "specVersion": { "type": "string", "description": "Spec version that this scan conforms to (e.g. `0.1.0`)." }
      }
    },
    "roots": {
      "type": "array",
      "description": "Filesystem roots that were walked during this scan, as absolute or scope-root-relative paths.",
      "minItems": 1,
      "items": { "type": "string" }
    },
    "providers": {
      "type": "array",
      "description": "Provider ids that participated in classification. Empty if no Provider matched.",
      "items": { "type": "string" }
    },
    "tokenizer": {
      "type": "string",
      "description": "Resolved offline tokenizer (encoder) that produced the per-node token counts in this scan, one of the closed allow-list in `project-config.schema.json#/properties/tokenizer` (`cl100k_base` default, `o200k_base`). Mirrors `scan_meta.tokenizer`. Reported so consumers know which encoder the counts came from; the incremental scan compares the persisted value against the resolved one and force-recomputes counts when they differ. Absent on synthetic fixtures and when tokenization was disabled."
    },
    "activeProvider": {
      "type": ["string", "null"],
      "description": "Active provider LENS this scan ran under: the id of the gated Provider whose grammar the corpus was read with (see `architecture.md` §Active-lens scope for providers), `null` when no lens is resolvable. Mirrors `scan_meta.active_provider`. Reported so consumers know which lens produced the classification; the incremental scan compares the persisted value against the resolved one and re-classifies every node when they differ (the lens decides which Provider claims a file, as which kind). Absent on synthetic fixtures.",
      "default": null
    },
    "scanCeiling": {
      "type": "integer",
      "minimum": 1,
      "description": "Effective walk ceiling for this scan (`--max-scan <N>` override on `sm scan` / `sm watch` / `sm serve`, else `scan.maxScan` from settings, default 5000). The scan walks, parses, analyzes, and reference-validates the full corpus up to this number, so references resolve across the whole project regardless of how many nodes the map renders. Mirrors `scan_meta.scan_ceiling`. Absent on synthetic fixtures."
    },
    "scanTruncated": {
      "type": "boolean",
      "description": "True when the walker reached `scanCeiling` and dropped files in stable provider-walker order, false otherwise. Reported so the UI raises the persistent 'scan truncated' banner pointing at the `.skillmapignore` editor. Mirrors `scan_meta.scan_truncated`. Absent on synthetic fixtures."
    },
    "maxRenderNodes": {
      "type": "integer",
      "minimum": 1,
      "description": "Effective map render cap for this scan (`--max-nodes <N>` override, else `scan.maxNodes` from settings, default 256). Does NOT bound the scan (the full corpus up to `scanCeiling` is persisted and the folders tree shows all of it); it only bounds the graph projection. The UI projects the selected folder branch capped at this number and raises an in-view banner when a branch exceeds it. Mirrors `scan_meta.max_render_nodes`. Absent on synthetic fixtures."
    },
    "oversizedFiles": {
      "type": "array",
      "description": "Files the walker skipped because their on-disk size exceeded `scan.maxFileSizeBytes` (default 1 MiB). Reported so the CLI / serve terminal can warn and the UI can raise a banner. Each entry is the root-relative, forward-slash path (same form as `node.path`) plus the file's byte size. Skipped files are never read, parsed, or indexed as nodes. Empty when no file exceeded the limit; defaults to `[]`.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["path", "bytes"],
        "properties": {
          "path": {
            "type": "string",
            "description": "Root-relative, forward-slash path of the skipped file (same form as `node.path`)."
          },
          "bytes": {
            "type": "integer",
            "minimum": 0,
            "description": "On-disk size of the skipped file, in bytes."
          }
        }
      }
    },
    "nodes": {
      "type": "array",
      "items": { "$ref": "node.schema.json" }
    },
    "links": {
      "type": "array",
      "items": { "$ref": "link.schema.json" }
    },
    "issues": {
      "type": "array",
      "items": { "$ref": "issue.schema.json" }
    },
    "stats": {
      "type": "object",
      "required": ["filesWalked", "filesSkipped", "nodesCount", "linksCount", "issuesCount", "durationMs"],
      "additionalProperties": false,
      "properties": {
        "filesWalked": { "type": "integer", "minimum": 0 },
        "filesSkipped": { "type": "integer", "minimum": 0, "description": "Files walked but not classified by any Provider." },
        "filesOversized": { "type": "integer", "minimum": 0, "description": "Files skipped before reading because their on-disk size exceeded `scan.maxFileSizeBytes`. Equals `oversizedFiles.length`. Absent on synthetic fixtures that bypass the walker." },
        "nodesCount": { "type": "integer", "minimum": 0 },
        "linksCount": { "type": "integer", "minimum": 0 },
        "issuesCount": { "type": "integer", "minimum": 0 },
        "durationMs": { "type": "integer", "minimum": 0 }
      }
    }
  }
}
