start-vibing 4.3.1 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "start-vibing",
3
- "version": "4.3.1",
4
- "description": "Setup Claude Code with 9 plugins, 6 community skills, and 8 MCP servers. Parallel install, auto-accept, superpowers + ralph-loop. super-design 0.6.1: compass-artifact alignment WCAG 2.2 SCs, CrUX field data, tool triangulation, Baymard sub-rules, Doherty/Tesler/Postel rationale, atomic state write, unshallow ladder.",
3
+ "version": "4.3.2",
4
+ "description": "Setup Claude Code with 9 plugins, 6 community skills, and 8 MCP servers. Parallel install, auto-accept, superpowers + ralph-loop. super-design 0.6.2: canonical audit-state.schema.json + verify --strict, per-viewport {sha256,phash,png_path} hashes with sharp/fpr fallback + MASK_SELECTORS, visual-regression.sh (pixelmatch→odiff→sha256-fallback), DTCG *.tokens.json + Tokens Studio aliases, @fixture-<id> dynamic routes + madge import-graph N=3, per-app monorepo state (pnpm/npm/yarn/Bun/Nx/Turbo).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "start-vibing": "./dist/cli.js"
@@ -8,7 +8,7 @@ description: >
8
8
  UX audit (WCAG 2.2 AA, Nielsen heuristics, Baymard, CWV), and synthesized
9
9
  overview. Re-audits only what changed since last run. On explicit user request,
10
10
  applies surgical fixes with full rollback.
11
- version: 0.6.1
11
+ version: 0.6.2
12
12
  ---
13
13
 
14
14
  # super-design
@@ -105,10 +105,96 @@ Do NOT paste overview into chat.
105
105
  - `--refresh-research` — rerun sd-research
106
106
  - `--only <cat>` — a11y | design | ux | perf | research
107
107
  - `--scope <url>` — specific route
108
+ - `--app <name>` — scope the entire run to one monorepo app (matches a
109
+ `name` entry from `scripts/detect-apps.sh`). Required when `--scope <url>`
110
+ is ambiguous between multiple apps.
108
111
  - `--fix` — run sd-fix after audit
109
112
  - `--dry-run` — artifacts without committing state
110
113
  - `--ci` — non-interactive, create PR, exit non-zero on blockers
111
- - `--update-baselines` — Re-hash pages and tokens without re-auditing (use after accepted cosmetic drift)
114
+ - `--update-baselines` — Re-hash pages and tokens without re-auditing (use after accepted cosmetic drift). Also accepted by `scripts/visual-regression.sh` to overwrite `.super-design/baselines/*.png` with the current capture.
115
+ - `--visual-regression` — Run `scripts/visual-regression.sh` after hashing. Reads the `visual_regression` block from `.audit-state.json` (engine: pixelmatch | odiff | sha256-fallback; threshold 0.1; max_diff_pixel_ratio 0.01). See artifact §16.
116
+ - `MASK_SELECTORS=<sel,sel,...>` (env) — Extra CSS selectors masked in every screenshot captured by `scripts/hash-pages.sh`. Artifact §3.4 defaults (`[data-timestamp], .relative-time, [data-react-hydration], video, canvas`) are always applied.
117
+
118
+ ## Monorepo support
119
+
120
+ Audit state is per-app (artifact §11 line 902) so independent deploys
121
+ carry independent freshness, `git_sha_at_audit`, and tool results. Layout
122
+ is auto-detected; nothing else to configure.
123
+
124
+ ### Detection
125
+
126
+ `scripts/detect-apps.sh` reads the first workspace manifest it finds:
127
+
128
+ | Manifest | Source of globs |
129
+ |----------|-----------------|
130
+ | `pnpm-workspace.yaml` | `packages:` list |
131
+ | `package.json` | `workspaces: [...]` or `workspaces.packages: [...]` (npm, yarn, Bun) |
132
+ | `turbo.json` | Presence → uses pnpm/npm/yarn workspaces; falls back to `apps/*` + `packages/*` if none |
133
+ | `nx.json` | `workspaceLayout.appsDir` / `libsDir` (default `apps/*`, `libs/*`) |
134
+ | `bunfig.toml` | Presence → falls back to `apps/*` + `packages/*` if package.json has no workspaces |
135
+
136
+ Each matched directory that also has a `package.json` becomes an app
137
+ with `name` taken from `package.json#name` (scope stripped), `path` the
138
+ directory, and `state_path` = `<path>/docs/super-design/.audit-state.json`.
139
+ If nothing matches, `detect-apps.sh` emits a `single` layout with
140
+ `path: "."` and the repo-root state path — preserving existing single-app
141
+ behavior.
142
+
143
+ ### Per-app pipeline
144
+
145
+ - **Preflight**: per app, read `<app>/docs/super-design/.audit-state.json`
146
+ via `validate-state.sh <app_path>`.
147
+ - **Change detection**: `scripts/detect-changes.sh --all-apps` loops over
148
+ every app and narrows `git diff` to `-- <app_path>/` so each app's
149
+ scope decision sees only its own files. Single-app shape is preserved
150
+ with `detect-changes.sh <last_sha>`.
151
+ - **Write state**: `scripts/write-state.sh <app_path>` derives the target
152
+ path; for single-app repos pass `.` or omit.
153
+
154
+ ### URL → app disambiguation
155
+
156
+ `--scope <url>` still targets one URL. When the URL maps cleanly to a
157
+ single app (e.g. `apps/admin` serves `https://admin.example.com`), the
158
+ pipeline picks that app automatically. When mapping is ambiguous
159
+ (multiple apps serve overlapping hostnames, or URL patterns cross apps),
160
+ the user MUST pass `--app <name>` — otherwise the skill aborts with a
161
+ `{"error":"ambiguous-app","candidates":[...]}` verdict instead of
162
+ guessing.
163
+
164
+ ## Scripts
165
+
166
+ Reusable shell helpers under `scripts/`. All POSIX/bash, tested on
167
+ Windows git-bash + Linux.
168
+
169
+ - `discover-routes.sh` — emits `route_map` as a JSON array. Dynamic
170
+ segments (`[slug]`, `[[...all]]`, `$id`, `:uid`) are suffixed with
171
+ `@fixture-<id>` (artifact §2.7). Fixtures resolved from sibling
172
+ `*.fixture.json`, `fixtures/<name>.json`, or `$SUPER_DESIGN_FIXTURES`
173
+ env JSON; falls back to `@fixture-default` with a warning. Consumers
174
+ (hash-pages, sd-audit) MUST strip the suffix before navigating.
175
+ - `build-import-graph.sh` — builds `.super-design/import-graph.json`
176
+ (`{nodes, edges, hash, backend}`) and persists `import_graph_sha` to
177
+ state. Prefers `npx madge --json <roots>`; falls back to a regex
178
+ scanner (JS/TS only, no alias resolution) if madge is missing.
179
+ - Query: `bash .../build-import-graph.sh importers <file> --hops 3`
180
+ → BFS over reversed edges; `detect-changes.sh` uses this to close
181
+ the component→pages gap when only components changed (Step 2 scope
182
+ decision: "Only components changed → re-audit pages importing them
183
+ (N=3 hops via madge)").
184
+ - `hash-pages.sh` — captures 3 viewports per URL (mobile_375, tablet_768,
185
+ desktop_1280), emits `{html_hash, dom_structure_hash, viewport_hashes:
186
+ {<vp>: {sha256, phash, png_path}}}` per page to
187
+ `docs/super-design/.cache/hashes/hashes.json` and persists each PNG to
188
+ `<cache>/screenshots/<url-enc>/<vp>.png`. Applies artifact §3.4 mask
189
+ defaults plus `MASK_SELECTORS`; `phash` uses `sharp` when available
190
+ (tagged `phash:`) or a deterministic PNG fingerprint otherwise
191
+ (tagged `fpr:`, only useful for exact-match comparison).
192
+ - `visual-regression.sh [--update-baselines] [<state>]` — reads the
193
+ `visual_regression` block from `.audit-state.json` and diffs current
194
+ screenshots against `.super-design/baselines/`. Engine chain:
195
+ `pixelmatch` → `odiff` → `sha256-fallback`. Emits
196
+ `{page, viewport, diff_ratio, threshold, pass, diff_image_path}` to
197
+ `<diff_dir>/results.json`. Exits non-zero if any page fails.
112
198
 
113
199
  ## References (Read on demand)
114
200
 
@@ -0,0 +1,226 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://hakutaku.ai/schemas/super-design/audit-state.schema.json",
4
+ "title": "super-design audit state",
5
+ "description": "Canonical schema for docs/super-design/.audit-state.json — the state file that super-design reads on every run to decide what to re-audit. Derived from docs/compass_artifact_wf-f52f98c2-73c9-4483-b49c-6b080ef7dc92_text_markdown.md sections 5, 10, and 16.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "required": [
9
+ "schema_version",
10
+ "skill_version",
11
+ "last_audit_at",
12
+ "git_sha_at_audit"
13
+ ],
14
+ "properties": {
15
+ "schema_version": {
16
+ "type": "string",
17
+ "description": "Semver string for this state file's schema. Major bumps force a full re-audit (see §12 line 934, §10).",
18
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
19
+ },
20
+ "skill_version": {
21
+ "type": "string",
22
+ "description": "Semver of the super-design skill that wrote this state.",
23
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
24
+ },
25
+ "last_audit_at": {
26
+ "type": "string",
27
+ "description": "ISO-8601 timestamp of the last completed audit (used for freshness cascade: 90d soft, 180d hard).",
28
+ "format": "date-time"
29
+ },
30
+ "git_sha_at_audit": {
31
+ "type": "string",
32
+ "description": "Git SHA (7–64 hex chars) that HEAD pointed to at audit time. Used as the range-start for next audit's git log (§2.1).",
33
+ "pattern": "^[0-9a-f]{7,64}$"
34
+ },
35
+ "git_branch": {
36
+ "type": "string",
37
+ "description": "Branch name at audit time (informational)."
38
+ },
39
+ "is_shallow_clone": {
40
+ "type": "boolean",
41
+ "description": "True if the repo was a shallow clone at audit time. Signals that __MISSING__ fallback (§6) may need git fetch --unshallow."
42
+ },
43
+
44
+ "theory_doc_sha": {
45
+ "type": "string",
46
+ "description": "sha256: of references/design-theory.md. Mismatch forces full re-audit (§9.1).",
47
+ "pattern": "^sha256:[0-9a-f]{4,64}$"
48
+ },
49
+ "market_analysis_sha": {
50
+ "type": "string",
51
+ "description": "sha256: of the current market-analysis.md output.",
52
+ "pattern": "^sha256:[0-9a-f]{4,64}$"
53
+ },
54
+
55
+ "tools": {
56
+ "type": "object",
57
+ "description": "Versions of the audit toolchain at audit time. Major bumps can invalidate prior findings.",
58
+ "additionalProperties": true,
59
+ "properties": {
60
+ "axe-core": { "type": "string" },
61
+ "lighthouse": { "type": "string" },
62
+ "playwright": { "type": "string" },
63
+ "playwright-mcp": { "type": "string" },
64
+ "pa11y": { "type": "string" }
65
+ }
66
+ },
67
+
68
+ "framework": {
69
+ "type": "object",
70
+ "description": "Detected framework info. Router/version migration triggers full re-audit (§7).",
71
+ "additionalProperties": true,
72
+ "properties": {
73
+ "name": { "type": "string" },
74
+ "router": { "type": "string" },
75
+ "version": { "type": "string" }
76
+ },
77
+ "required": ["name"]
78
+ },
79
+
80
+ "route_map": {
81
+ "type": "array",
82
+ "description": "Canonical route list at audit time. Diff against prior route_map produces new/deleted routes (§7).",
83
+ "items": { "type": "string" }
84
+ },
85
+
86
+ "pages_audited": {
87
+ "type": "array",
88
+ "description": "Per-page hashes + findings (§3, §5). Incremental audits compare these to decide which pages to re-visit.",
89
+ "items": {
90
+ "type": "object",
91
+ "additionalProperties": true,
92
+ "required": ["url"],
93
+ "properties": {
94
+ "url": { "type": "string" },
95
+ "route_file": { "type": "string" },
96
+ "html_hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{4,64}$" },
97
+ "dom_structure_hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{4,64}$" },
98
+ "screenshot_hash": { "type": "string", "pattern": "^(sha256|phash):[0-9a-f]{4,64}$" },
99
+ "viewport_hashes": {
100
+ "type": "object",
101
+ "description": "Per-viewport hashes (mobile_375/tablet_768/desktop_1280). Each entry may be a bare phash string (back-compat with artifact §10 line 492) or the extended {sha256,phash,png_path} object emitted by hash-pages.sh. Any viewport drift -> re-audit that page.",
102
+ "additionalProperties": {
103
+ "oneOf": [
104
+ { "type": "string" },
105
+ {
106
+ "type": "object",
107
+ "properties": {
108
+ "sha256": { "type": "string" },
109
+ "phash": { "type": "string" },
110
+ "png_path": { "type": "string" }
111
+ }
112
+ }
113
+ ]
114
+ }
115
+ },
116
+ "mask_selectors": {
117
+ "type": "array",
118
+ "description": "CSS selectors masked in this page's screenshots (artifact §3.4). Merged from hash-pages.sh defaults + MASK_SELECTORS env.",
119
+ "items": { "type": "string" }
120
+ },
121
+ "phash_engine": {
122
+ "type": "string",
123
+ "description": "Which engine produced the phash values. `phash:` = sharp-based aHash. `fpr:` = zero-dep PNG fingerprint fallback (exact-match only)."
124
+ },
125
+ "last_audited": { "type": "string", "format": "date-time" },
126
+ "findings_ids": {
127
+ "type": "array",
128
+ "items": { "type": "string" }
129
+ }
130
+ }
131
+ }
132
+ },
133
+
134
+ "components": {
135
+ "type": "object",
136
+ "description": "Map of component path -> xxh3 hash (§8). Source of truth for component-level change detection.",
137
+ "additionalProperties": { "type": "string" }
138
+ },
139
+
140
+ "token_hash": {
141
+ "type": "string",
142
+ "description": "sha256: hash of the canonicalized design-token map. Mismatch invalidates every page (§9).",
143
+ "pattern": "^sha256:[0-9a-f]{4,64}$"
144
+ },
145
+
146
+ "import_graph_sha": {
147
+ "type": "string",
148
+ "description": "sha256: of the serialized import graph (madge). Used to compute blast radius of component changes.",
149
+ "pattern": "^sha256:[0-9a-f]{4,64}$"
150
+ },
151
+
152
+ "findings_counts": {
153
+ "type": "object",
154
+ "description": "Aggregate tally after last audit (§5).",
155
+ "additionalProperties": false,
156
+ "properties": {
157
+ "blockers": { "type": "integer", "minimum": 0 },
158
+ "high": { "type": "integer", "minimum": 0 },
159
+ "medium": { "type": "integer", "minimum": 0 },
160
+ "nitpicks": { "type": "integer", "minimum": 0 }
161
+ }
162
+ },
163
+
164
+ "research_at": {
165
+ "type": "string",
166
+ "description": "ISO-8601 timestamp of the last sd-research run. Used to decide whether to rerun research (>90d -> refresh).",
167
+ "format": "date-time"
168
+ },
169
+
170
+ "ignored_paths": {
171
+ "type": "array",
172
+ "description": "Globs excluded from design-relevance classification (§2.3).",
173
+ "items": { "type": "string" }
174
+ },
175
+
176
+ "visual_regression": {
177
+ "type": "object",
178
+ "description": "Optional visual-regression config (§16). Absent when user hasn't opted in. Consumed by scripts/visual-regression.sh.",
179
+ "additionalProperties": true,
180
+ "properties": {
181
+ "enabled": { "type": "boolean", "default": false },
182
+ "engine": {
183
+ "type": "string",
184
+ "description": "Engine chain: scripts/visual-regression.sh tries in order and falls back. `sha256-fallback` = exact-match only, always available.",
185
+ "enum": ["pixelmatch", "odiff", "resemble", "looks-same", "playwright", "sha256-fallback"],
186
+ "default": "pixelmatch"
187
+ },
188
+ "threshold": { "type": "number", "minimum": 0, "default": 0.1 },
189
+ "max_diff_pixel_ratio": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.01 },
190
+ "antialiasing": { "type": "boolean", "default": true },
191
+ "viewports": {
192
+ "type": "array",
193
+ "items": {
194
+ "type": "object",
195
+ "required": ["label", "width", "height"],
196
+ "properties": {
197
+ "label": { "type": "string" },
198
+ "width": { "type": "integer", "minimum": 1 },
199
+ "height": { "type": "integer", "minimum": 1 }
200
+ }
201
+ }
202
+ },
203
+ "mask_selectors": {
204
+ "type": "array",
205
+ "items": { "type": "string" }
206
+ },
207
+ "baseline_dir": {
208
+ "type": "string",
209
+ "description": "Where accepted baseline PNGs live. Committed to git (small repos) or stored via LFS/artifacts (large).",
210
+ "default": ".super-design/baselines"
211
+ },
212
+ "current_dir": {
213
+ "type": "string",
214
+ "description": "Where hash-pages.sh writes the current run's PNGs. Usually gitignored.",
215
+ "default": "docs/super-design/.cache/hashes/screenshots"
216
+ },
217
+ "diff_dir": {
218
+ "type": "string",
219
+ "description": "Where diff images + results.json are emitted by visual-regression.sh.",
220
+ "default": "docs/super-design/.cache/hashes/diffs"
221
+ },
222
+ "docker_image": { "type": "string" }
223
+ }
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env bash
2
+ # build-import-graph.sh — build a JS/TS import graph for the repo so
3
+ # super-design can propagate component-level changes to pages within N
4
+ # hops (artifact §8, line 666: default N=3).
5
+ #
6
+ # Usage:
7
+ # build-import-graph.sh [--state <path>] # full build
8
+ # build-import-graph.sh importers <file> [--hops N] # BFS query
9
+ #
10
+ # Output (full build): .super-design/import-graph.json with shape
11
+ # {
12
+ # "nodes": ["src/app/page.tsx", ...],
13
+ # "edges": [{"from":"src/app/page.tsx","to":"src/components/Nav.tsx"}, ...],
14
+ # "hash": "sha256:<hex>",
15
+ # "backend": "madge" | "regex-fallback",
16
+ # "built_at":"<ISO-8601>"
17
+ # }
18
+ #
19
+ # State: writes `import_graph_sha` back into .audit-state.json via
20
+ # scripts/write-state.sh. The sha lives in audit-state.schema.json so
21
+ # detect-changes.sh can short-circuit re-propagation if the graph is
22
+ # unchanged.
23
+ #
24
+ # Backend choice:
25
+ # 1. If `npx madge --version` works, use `npx madge --json <roots>`
26
+ # (artifact §8: madge reads .madgerc / package.json#madge).
27
+ # 2. Else fall back to a zero-dep regex scanner (JS/TS/JSX/TSX only —
28
+ # no CSS/Vue/Svelte detectives, no alias resolution). Logs a
29
+ # warning so the caller knows propagation is best-effort.
30
+ set -euo pipefail
31
+
32
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
+ STATE_PATH="docs/super-design/.audit-state.json"
34
+ OUT_DIR=".super-design"
35
+ OUT_FILE="$OUT_DIR/import-graph.json"
36
+
37
+ log() { printf '[build-import-graph] %s\n' "$*" >&2; }
38
+
39
+ # Source roots — read from state, else default.
40
+ resolve_roots() {
41
+ local roots=""
42
+ if [[ -f "$STATE_PATH" ]]; then
43
+ roots="$(jq -r '.source_roots // empty | .[]?' "$STATE_PATH" 2>/dev/null || true)"
44
+ fi
45
+ if [[ -z "$roots" ]]; then
46
+ for d in src app pages; do [[ -d "$d" ]] && echo "$d"; done
47
+ else
48
+ printf '%s\n' "$roots"
49
+ fi
50
+ }
51
+
52
+ # --- Full build -----------------------------------------------------------
53
+
54
+ build_madge() {
55
+ # madge prints { "file": [deps], ... }. We flatten to edges.
56
+ local roots_json
57
+ roots_json="$(resolve_roots | jq -Rn '[inputs]')"
58
+ local roots
59
+ roots="$(printf '%s' "$roots_json" | jq -r '.[]' | tr '\n' ' ')"
60
+ [[ -z "$roots" ]] && { log "no source roots found"; echo '{}'; return; }
61
+ # shellcheck disable=SC2086
62
+ npx --yes madge --json $roots 2>/dev/null
63
+ }
64
+
65
+ build_regex_fallback() {
66
+ log "madge unavailable; using regex fallback (JS/TS only, no alias resolution)"
67
+ local roots
68
+ roots="$(resolve_roots | tr '\n' ' ')"
69
+ [[ -z "$roots" ]] && { echo '{}'; return; }
70
+
71
+ # Emit NDJSON: {"from":"<path>","to":"<dep>"} for each import line in
72
+ # each JS/TS file, then fold into {file: [deps]} via jq.
73
+ # shellcheck disable=SC2086
74
+ find $roots -type f \( \
75
+ -name '*.ts' -o -name '*.tsx' \
76
+ -o -name '*.js' -o -name '*.jsx' \
77
+ -o -name '*.mjs' -o -name '*.cjs' \) 2>/dev/null \
78
+ | while IFS= read -r f; do
79
+ # Grep import/require specifiers. Handles:
80
+ # import ... from 'x'
81
+ # import 'x'
82
+ # require('x')
83
+ # import('x') (dynamic)
84
+ # export ... from 'x'
85
+ grep -hoE "(from|require|import)[[:space:]]*\(?[[:space:]]*['\"][^'\"]+['\"]" "$f" 2>/dev/null \
86
+ | sed -E "s|.*['\"]([^'\"]+)['\"].*|\1|" \
87
+ | while IFS= read -r spec; do
88
+ [[ -z "$spec" ]] && continue
89
+ # Skip bare package specifiers for graph purposes (we only
90
+ # want local file edges to compute importers).
91
+ case "$spec" in
92
+ .*|/*) : ;;
93
+ *) continue ;;
94
+ esac
95
+ # Normalize: resolve relative to $f's directory, best-effort.
96
+ local dir resolved
97
+ dir="$(dirname "$f")"
98
+ resolved="$dir/$spec"
99
+ # Strip ./ and trailing /
100
+ resolved="$(printf '%s' "$resolved" \
101
+ | sed -E 's|/\./|/|g; s|/$||')"
102
+ jq -cn --arg from "$f" --arg to "$resolved" '{from:$from,to:$to}'
103
+ done
104
+ done \
105
+ | jq -sc 'group_by(.from) | map({key:.[0].from, value:(map(.to)|unique)}) | from_entries'
106
+ }
107
+
108
+ build_graph_json() {
109
+ local raw
110
+ if npx --yes madge --version >/dev/null 2>&1; then
111
+ raw="$(build_madge || echo '{}')"
112
+ BACKEND="madge"
113
+ else
114
+ raw="$(build_regex_fallback || echo '{}')"
115
+ BACKEND="regex-fallback"
116
+ fi
117
+ [[ -z "$raw" ]] && raw='{}'
118
+ printf '%s' "$raw"
119
+ }
120
+
121
+ emit_final() {
122
+ local raw="$1"
123
+ mkdir -p "$OUT_DIR"
124
+ # Build nodes + edges from the {file: [deps]} shape. Keep original
125
+ # paths verbatim (do not attempt alias resolution here).
126
+ local body
127
+ body="$(printf '%s' "$raw" | jq --arg backend "$BACKEND" --arg now "$(date -u +%FT%TZ)" '
128
+ . as $g
129
+ | ([keys[], (.[] | .[])] | unique) as $nodes
130
+ | [to_entries[] | .key as $from | .value[] | {from:$from, to:.}] as $edges
131
+ | {nodes:$nodes, edges:$edges, backend:$backend, built_at:$now}
132
+ ')"
133
+ # Hash over nodes+edges (stable serialization via jq --sort-keys -c).
134
+ local hash
135
+ hash="$(printf '%s' "$body" | jq -S -c '{nodes, edges}' | sha256sum | awk '{print "sha256:"$1}')"
136
+ printf '%s' "$body" | jq --arg h "$hash" '. + {hash:$h}' > "$OUT_FILE"
137
+
138
+ # Update state.import_graph_sha if state file exists.
139
+ if [[ -f "$STATE_PATH" ]]; then
140
+ jq --arg h "$hash" '. + {import_graph_sha:$h}' "$STATE_PATH" \
141
+ | bash "$SCRIPT_DIR/write-state.sh" "$STATE_PATH" >/dev/null \
142
+ || log "failed to persist import_graph_sha to state"
143
+ fi
144
+
145
+ jq -n --arg path "$OUT_FILE" --arg hash "$hash" --arg backend "$BACKEND" \
146
+ --argjson nodes "$(jq '.nodes|length' "$OUT_FILE")" \
147
+ --argjson edges "$(jq '.edges|length' "$OUT_FILE")" \
148
+ '{status:"ok", path:$path, hash:$hash, backend:$backend, nodes:$nodes, edges:$edges}'
149
+ }
150
+
151
+ # --- Query: importers_of --------------------------------------------------
152
+
153
+ # importers_of <file> [--hops N]
154
+ # BFS over the edge list using jq. Emits one path per line.
155
+ importers_of() {
156
+ local file="$1"; shift || true
157
+ local hops=3
158
+ while [[ $# -gt 0 ]]; do
159
+ case "$1" in
160
+ --hops) hops="${2:-3}"; shift 2 ;;
161
+ *) shift ;;
162
+ esac
163
+ done
164
+ [[ -f "$OUT_FILE" ]] || { log "no import graph; run build first"; return 1; }
165
+ jq -r --arg start "$file" --argjson hops "$hops" '
166
+ . as $g
167
+ # Reverse adjacency: to → [from...]
168
+ | ([.edges[] | {key:.to, value:.from}] | group_by(.key)
169
+ | map({key:.[0].key, value:(map(.value)|unique)}) | from_entries) as $rev
170
+ | def bfs(frontier; seen; depth):
171
+ if depth >= $hops or (frontier|length)==0 then seen
172
+ else
173
+ (frontier | map($rev[.] // []) | add // [] | unique) as $next
174
+ | ($next - (seen|keys | map(.))) as $fresh
175
+ | bfs($fresh; (seen + ($fresh | map({(.):true}) | add // {})); depth + 1)
176
+ end;
177
+ bfs([$start]; {}; 0) | keys[]
178
+ ' "$OUT_FILE"
179
+ }
180
+
181
+ # --- Dispatch -------------------------------------------------------------
182
+
183
+ main() {
184
+ case "${1:-build}" in
185
+ build|"")
186
+ shift || true
187
+ while [[ $# -gt 0 ]]; do
188
+ case "$1" in
189
+ --state) STATE_PATH="${2:?}"; shift 2 ;;
190
+ *) shift ;;
191
+ esac
192
+ done
193
+ raw="$(build_graph_json)"
194
+ emit_final "$raw"
195
+ ;;
196
+ importers)
197
+ shift
198
+ [[ -z "${1:-}" ]] && { log "usage: importers <file> [--hops N]"; exit 2; }
199
+ importers_of "$@"
200
+ ;;
201
+ *)
202
+ log "unknown subcommand: $1"
203
+ exit 2
204
+ ;;
205
+ esac
206
+ }
207
+
208
+ main "$@"