start-vibing 4.3.0 → 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.
Files changed (22) hide show
  1. package/package.json +2 -2
  2. package/template/.claude/agents/sd-audit.md +121 -2
  3. package/template/.claude/agents/sd-fix.md +11 -0
  4. package/template/.claude/agents/sd-research.md +49 -2
  5. package/template/.claude/skills/super-design/.schema-version +1 -0
  6. package/template/.claude/skills/super-design/SKILL.md +94 -2
  7. package/template/.claude/skills/super-design/audit-state.schema.json +226 -0
  8. package/template/.claude/skills/super-design/references/audit-methodology.md +118 -0
  9. package/template/.claude/skills/super-design/references/design-intelligence-rubric.md +92 -11
  10. package/template/.claude/skills/super-design/references/design-skills-catalog.md +31 -0
  11. package/template/.claude/skills/super-design/scripts/build-import-graph.sh +208 -0
  12. package/template/.claude/skills/super-design/scripts/detect-apps.sh +180 -0
  13. package/template/.claude/skills/super-design/scripts/detect-changes.sh +177 -21
  14. package/template/.claude/skills/super-design/scripts/discover-routes.sh +120 -6
  15. package/template/.claude/skills/super-design/scripts/extract-tokens.mjs +165 -4
  16. package/template/.claude/skills/super-design/scripts/hash-pages.sh +209 -23
  17. package/template/.claude/skills/super-design/scripts/setup-git-notes.sh +21 -0
  18. package/template/.claude/skills/super-design/scripts/validate-state.sh +74 -11
  19. package/template/.claude/skills/super-design/scripts/verify-audit.sh +62 -9
  20. package/template/.claude/skills/super-design/scripts/visual-regression.sh +275 -0
  21. package/template/.claude/skills/super-design/scripts/write-state.sh +53 -0
  22. package/template/.claude/skills/super-design/templates/audit-state.schema.json +0 -57
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: detect-apps.sh [<repo_root>]
3
+ #
4
+ # Detects monorepo layout for super-design per-app audit state (artifact §11
5
+ # line 902). Reads workspace declarations from the known manifests:
6
+ # - pnpm-workspace.yaml (pnpm)
7
+ # - package.json#workspaces (npm / yarn / bun)
8
+ # - turbo.json (Turborepo — infers apps from pipeline scope)
9
+ # - nx.json / workspace.json (Nx)
10
+ # - bunfig.toml (Bun workspaces)
11
+ # For each matched glob we enumerate directories that also contain a
12
+ # package.json; those are the apps. When no workspace config is found we
13
+ # emit a single "single"-layout entry with path ".".
14
+ #
15
+ # Output JSON:
16
+ # {
17
+ # "layout": "monorepo" | "single",
18
+ # "apps": [
19
+ # { "name": "web",
20
+ # "path": "apps/web",
21
+ # "state_path": "apps/web/docs/super-design/.audit-state.json" }
22
+ # ]
23
+ # }
24
+ #
25
+ # POSIX-ish bash. Requires: jq. Works under git-bash on Windows (no GNU
26
+ # find -printf, no readarray).
27
+ set -euo pipefail
28
+
29
+ ROOT="${1:-.}"
30
+ cd "$ROOT"
31
+
32
+ log() { printf '[detect-apps] %s\n' "$*" >&2; }
33
+
34
+ # --- Collect workspace globs from whichever manifests exist ------------------
35
+ GLOBS=""
36
+
37
+ append_glob() {
38
+ # $1 = glob string; skip empty, comments, and negative (!) patterns for now.
39
+ local g
40
+ g="$(printf '%s' "$1" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
41
+ [ -z "$g" ] && return 0
42
+ case "$g" in
43
+ '#'*) return 0 ;;
44
+ '!'*) return 0 ;;
45
+ esac
46
+ GLOBS="${GLOBS}
47
+ ${g}"
48
+ }
49
+
50
+ # pnpm-workspace.yaml — very small YAML subset parser: lines under
51
+ # "packages:" that start with "- ".
52
+ if [ -f "pnpm-workspace.yaml" ]; then
53
+ in_pkgs=0
54
+ while IFS= read -r line; do
55
+ case "$line" in
56
+ packages:*) in_pkgs=1; continue ;;
57
+ esac
58
+ if [ "$in_pkgs" = "1" ]; then
59
+ case "$line" in
60
+ -\ *|\ *-\ *)
61
+ entry="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*-[[:space:]]*//' -e 's/^["'\'']//' -e 's/["'\'']$//')"
62
+ append_glob "$entry"
63
+ ;;
64
+ [A-Za-z]*:) in_pkgs=0 ;;
65
+ esac
66
+ fi
67
+ done < "pnpm-workspace.yaml"
68
+ fi
69
+
70
+ # package.json#workspaces (array or { packages: [...] })
71
+ if [ -f "package.json" ] && command -v jq >/dev/null 2>&1; then
72
+ WS_JSON="$(jq -r '
73
+ (.workspaces // empty)
74
+ | if type == "array" then .[]
75
+ elif type == "object" then (.packages // [])[]
76
+ else empty end
77
+ ' package.json 2>/dev/null || true)"
78
+ if [ -n "$WS_JSON" ]; then
79
+ while IFS= read -r g; do
80
+ [ -n "$g" ] && append_glob "$g"
81
+ done <<EOF
82
+ $WS_JSON
83
+ EOF
84
+ fi
85
+ fi
86
+
87
+ # turbo.json — Turborepo does not declare apps itself but relies on the
88
+ # package manager's workspaces. Treat its presence as confirmation that
89
+ # we are in a monorepo; globs come from package.json/pnpm-workspace.yaml
90
+ # which we've already read. If only turbo.json exists (rare), fall back
91
+ # to the conventional apps/* + packages/* layout.
92
+ if [ -f "turbo.json" ] && [ -z "$GLOBS" ]; then
93
+ append_glob "apps/*"
94
+ append_glob "packages/*"
95
+ fi
96
+
97
+ # nx.json / workspace.json — Nx. Apps live under apps/, libs/ (or the
98
+ # workspaceLayout override).
99
+ if [ -f "nx.json" ]; then
100
+ apps_dir="apps"
101
+ libs_dir="libs"
102
+ if command -v jq >/dev/null 2>&1; then
103
+ override_apps="$(jq -r '.workspaceLayout.appsDir // empty' nx.json 2>/dev/null || true)"
104
+ override_libs="$(jq -r '.workspaceLayout.libsDir // empty' nx.json 2>/dev/null || true)"
105
+ [ -n "$override_apps" ] && apps_dir="$override_apps"
106
+ [ -n "$override_libs" ] && libs_dir="$override_libs"
107
+ fi
108
+ append_glob "${apps_dir}/*"
109
+ append_glob "${libs_dir}/*"
110
+ fi
111
+
112
+ # bunfig.toml — Bun workspaces. Bun primarily reads workspaces from
113
+ # package.json#workspaces (already handled); bunfig.toml presence alone
114
+ # is a monorepo hint only if nothing else matched.
115
+ if [ -f "bunfig.toml" ] && [ -z "$GLOBS" ]; then
116
+ append_glob "apps/*"
117
+ append_glob "packages/*"
118
+ fi
119
+
120
+ # --- Expand globs to real directories that contain a package.json ------------
121
+ APPS_JSON='[]'
122
+ seen_paths=""
123
+
124
+ expand_glob() {
125
+ # $1 = glob like "apps/*" or "packages/marketing-site"
126
+ local g="$1"
127
+ # shellcheck disable=SC2086
128
+ # We deliberately let the shell expand the glob. POSIX shells don't set
129
+ # nullglob, so an unmatched pattern comes back as the literal string —
130
+ # we filter that out below by checking directory existence.
131
+ for d in $g; do
132
+ [ -d "$d" ] || continue
133
+ [ -f "$d/package.json" ] || continue
134
+ # Normalize: strip trailing slash, leading ./
135
+ p="$(printf '%s' "$d" | sed -e 's#^\./##' -e 's#/$##')"
136
+ case "$seen_paths" in
137
+ *"|${p}|"*) continue ;;
138
+ esac
139
+ seen_paths="${seen_paths}|${p}|"
140
+ # Name = package.json "name" field (last path segment for scoped names),
141
+ # fallback to basename.
142
+ name="$(basename "$p")"
143
+ if command -v jq >/dev/null 2>&1; then
144
+ pkg_name="$(jq -r '.name // empty' "$p/package.json" 2>/dev/null || true)"
145
+ if [ -n "$pkg_name" ]; then
146
+ # Strip @scope/ prefix so "web" beats "@acme/web".
147
+ name="$(printf '%s' "$pkg_name" | sed -e 's#^@[^/]*/##')"
148
+ fi
149
+ fi
150
+ APPS_JSON="$(printf '%s' "$APPS_JSON" | jq --arg name "$name" --arg path "$p" \
151
+ --arg state "$p/docs/super-design/.audit-state.json" \
152
+ '. + [{name:$name, path:$path, state_path:$state}]')"
153
+ done
154
+ }
155
+
156
+ if [ -n "$GLOBS" ]; then
157
+ while IFS= read -r g; do
158
+ [ -z "$g" ] && continue
159
+ expand_glob "$g"
160
+ done <<EOF
161
+ $GLOBS
162
+ EOF
163
+ fi
164
+
165
+ APP_COUNT="$(printf '%s' "$APPS_JSON" | jq 'length')"
166
+
167
+ if [ "$APP_COUNT" -eq 0 ]; then
168
+ # No monorepo config matched OR matched but produced no apps with
169
+ # package.json. Emit single-app layout.
170
+ jq -n '{
171
+ layout: "single",
172
+ apps: [ {name: ".", path: ".", state_path: "docs/super-design/.audit-state.json"} ]
173
+ }'
174
+ exit 0
175
+ fi
176
+
177
+ jq -n --argjson apps "$APPS_JSON" '{
178
+ layout: "monorepo",
179
+ apps: $apps
180
+ }'
@@ -1,30 +1,184 @@
1
1
  #!/usr/bin/env bash
2
- # Usage: detect-changes.sh <last_sha> [<last_iso>]
3
- # Emits JSON: { mode, range_start, commits, files, classified }
2
+ # Usage:
3
+ # detect-changes.sh <last_sha> [<last_iso>] (single app)
4
+ # detect-changes.sh --app <path> <last_sha> [<last_iso>] (one app, path-scoped)
5
+ # detect-changes.sh --all-apps (monorepo loop)
6
+ #
7
+ # Emits JSON. Single-app form:
8
+ # { mode, range_start, commits, files, classified }
9
+ #
10
+ # Monorepo form (--all-apps) — one wrapper with per-app results; each
11
+ # app reads its own `.audit-state.json` via detect-apps.sh:
12
+ # { "layout": "monorepo"|"single",
13
+ # "apps": [ { "name", "path", "state_path",
14
+ # "last_sha", "changes": {<single-app form>} } ] }
15
+ #
16
+ # Implements the fallback ladder from artifact §6 + §14:
17
+ # 1. Anchor exists → diff with rename detection (-M90%).
18
+ # 2. Anchor missing + shallow repo → `git fetch --unshallow --no-tags`.
19
+ # 3. Still missing → fetch super-design git notes, retry.
20
+ # 4. Still missing → `--since=<iso>` time-based fallback.
21
+ # 5. Empty repo / no last_sha at all → diff against empty-tree SHA
22
+ # 4b825dc642cb6eb9a060e54bf8d69288fbee4904 (artifact line 900).
23
+ # Also uses --first-parent + --cherry-pick --right-only when walking
24
+ # history (artifact §2.5 line 167-172).
25
+ #
26
+ # Monorepo per-app state (artifact §11 line 902): when --app is given,
27
+ # `git diff --name-status` is narrowed via `-- <app_path>/` pathspec so
28
+ # only files under that app show up. `--all-apps` discovers apps via
29
+ # detect-apps.sh and runs the per-app path for each.
4
30
  set -euo pipefail
5
31
 
32
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
33
+ EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
34
+
35
+ log() { printf '[detect-changes] %s\n' "$*" >&2; }
36
+
37
+ # --- Arg parsing -------------------------------------------------------------
38
+ MODE_SELECT="single" # single | app | all
39
+ APP_PATH="."
40
+ if [ "${1:-}" = "--all-apps" ]; then
41
+ MODE_SELECT="all"; shift
42
+ elif [ "${1:-}" = "--app" ]; then
43
+ MODE_SELECT="app"; APP_PATH="${2:-.}"; shift 2
44
+ fi
6
45
  LAST_SHA="${1:-}"
7
46
  LAST_ISO="${2:-}"
8
47
 
9
- if [[ -z "$LAST_SHA" ]]; then echo '{"error":"missing last_sha"}'; exit 2; fi
10
48
  if ! git rev-parse --git-dir >/dev/null 2>&1; then echo '{"error":"not-a-git-repo"}'; exit 3; fi
11
49
 
12
- if git rev-parse --verify --quiet "${LAST_SHA}^{commit}" >/dev/null; then
13
- if git merge-base --is-ancestor "$LAST_SHA" HEAD; then RANGE_START="$LAST_SHA"
14
- else RANGE_START="$(git merge-base HEAD "$LAST_SHA" 2>/dev/null || echo "")"; fi
15
- else RANGE_START=""; fi
16
-
17
- if [[ -z "$RANGE_START" ]]; then
18
- if [[ -n "$LAST_ISO" ]]; then
19
- FILES="$(git log --since="$LAST_ISO" --name-only --pretty=format: 2>/dev/null | sort -u | sed '/^$/d' || true)"
20
- COMMITS="$(git log --since="$LAST_ISO" --pretty=format:'%H|%s|%an|%aI' 2>/dev/null || true)"
21
- MODE="since-time"
22
- else echo '{"error":"lost-anchor-no-fallback-time"}'; exit 4; fi
50
+ # --- --all-apps: discover via detect-apps.sh, recurse per app ----------------
51
+ if [ "$MODE_SELECT" = "all" ]; then
52
+ APPS_DOC="$(bash "$SCRIPT_DIR/detect-apps.sh" .)"
53
+ LAYOUT="$(printf '%s' "$APPS_DOC" | jq -r '.layout')"
54
+ RESULT='[]'
55
+ APP_COUNT="$(printf '%s' "$APPS_DOC" | jq '.apps | length')"
56
+ i=0
57
+ while [ "$i" -lt "$APP_COUNT" ]; do
58
+ APP_NAME="$(printf '%s' "$APPS_DOC" | jq -r ".apps[$i].name")"
59
+ A_PATH="$(printf '%s' "$APPS_DOC" | jq -r ".apps[$i].path")"
60
+ A_STATE="$(printf '%s' "$APPS_DOC" | jq -r ".apps[$i].state_path")"
61
+ A_LAST_SHA=""; A_LAST_ISO=""
62
+ if [ -f "$A_STATE" ]; then
63
+ A_LAST_SHA="$(jq -r '.git_sha_at_audit // ""' "$A_STATE" 2>/dev/null || true)"
64
+ A_LAST_ISO="$(jq -r '.last_audit_at // ""' "$A_STATE" 2>/dev/null || true)"
65
+ fi
66
+ A_CHANGES="$(bash "$0" --app "$A_PATH" "$A_LAST_SHA" "$A_LAST_ISO")"
67
+ RESULT="$(printf '%s' "$RESULT" | jq \
68
+ --arg name "$APP_NAME" --arg path "$A_PATH" --arg state "$A_STATE" \
69
+ --arg lsha "$A_LAST_SHA" --argjson ch "$A_CHANGES" '
70
+ . + [{name:$name, path:$path, state_path:$state, last_sha:$lsha, changes:$ch}]
71
+ ')"
72
+ i=$((i + 1))
73
+ done
74
+ jq -n --arg layout "$LAYOUT" --argjson apps "$RESULT" '{layout:$layout, apps:$apps}'
75
+ exit 0
76
+ fi
77
+
78
+ # Determine HEAD availability — empty repos have no commits at all.
79
+ if ! git rev-parse --verify --quiet HEAD >/dev/null; then
80
+ log "no HEAD commit; using empty-tree SHA fallback"
81
+ LAST_SHA="$EMPTY_TREE"
82
+ fi
83
+
84
+ # If caller didn't pass a last_sha, treat it as empty-tree (first audit).
85
+ if [[ -z "$LAST_SHA" ]]; then
86
+ log "missing last_sha; defaulting to empty-tree SHA"
87
+ LAST_SHA="$EMPTY_TREE"
88
+ fi
89
+
90
+ resolve_anchor() {
91
+ # Sets RANGE_START (may be empty if anchor unrecoverable).
92
+ if [[ "$LAST_SHA" == "$EMPTY_TREE" ]]; then
93
+ RANGE_START="$EMPTY_TREE"; return 0
94
+ fi
95
+ if git rev-parse --verify --quiet "${LAST_SHA}^{commit}" >/dev/null; then
96
+ if git merge-base --is-ancestor "$LAST_SHA" HEAD 2>/dev/null; then
97
+ RANGE_START="$LAST_SHA"
98
+ else
99
+ RANGE_START="$(git merge-base HEAD "$LAST_SHA" 2>/dev/null || echo "")"
100
+ fi
101
+ return 0
102
+ fi
103
+ RANGE_START=""
104
+ return 1
105
+ }
106
+
107
+ if ! resolve_anchor; then
108
+ # Ladder step (a): unshallow if possible.
109
+ if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then
110
+ log "anchor missing and repo is shallow; attempting git fetch --unshallow --no-tags"
111
+ git fetch --unshallow --no-tags 2>/dev/null || log "unshallow fetch failed (continuing)"
112
+ resolve_anchor || true
113
+ fi
114
+ fi
115
+ if [[ -z "${RANGE_START:-}" ]]; then
116
+ # Ladder step (b): try to fetch super-design notes; they may pin a commit
117
+ # we don't have locally.
118
+ log "anchor still missing; fetching refs/notes/super-design"
119
+ git fetch origin '+refs/notes/super-design:refs/notes/super-design' 2>/dev/null \
120
+ || log "notes fetch failed (continuing)"
121
+ resolve_anchor || true
122
+ fi
123
+
124
+ # Build the pathspec used to narrow diff/log to a single app (monorepo
125
+ # per-app state, artifact §11 line 902). For the default "." the scope
126
+ # is the repo root, preserving pre-existing single-app behavior.
127
+ case "$APP_PATH" in
128
+ .|"") SCOPE_PATH="." ;;
129
+ *) SCOPE_PATH="${APP_PATH%/}" ;;
130
+ esac
131
+
132
+ # Ladder step (c): time-based fallback.
133
+ if [[ -z "${RANGE_START:-}" && -n "$LAST_ISO" ]]; then
134
+ log "using --since=$LAST_ISO time-based fallback (scope=$SCOPE_PATH)"
135
+ FILES="$(git log --since="$LAST_ISO" --name-only --pretty=format: -- "$SCOPE_PATH" 2>/dev/null | sort -u | sed '/^$/d' || true)"
136
+ COMMITS="$(git log --first-parent --since="$LAST_ISO" --pretty=format:'%H|%s|%an|%aI' -- "$SCOPE_PATH" 2>/dev/null || true)"
137
+ MODE="since-time"
138
+ RANGE_START=""
139
+ elif [[ -z "${RANGE_START:-}" ]]; then
140
+ echo '{"error":"lost-anchor-no-fallback-time"}'; exit 4
23
141
  else
24
- FILES="$(git diff --name-only "${RANGE_START}..HEAD" \
25
- -- ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!yarn.lock' \
26
- ':!.github/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/*.stories.*' | sort -u)"
27
- COMMITS="$(git log --no-merges --pretty=format:'%H|%s|%an|%aI' "${RANGE_START}..HEAD" 2>/dev/null || true)"
142
+ # SHA range available. Use --name-status -M90% -z to catch renames AND
143
+ # filenames with spaces (NUL-terminated output).
144
+ RANGE="${RANGE_START}..HEAD"
145
+ if [[ "$RANGE_START" == "$EMPTY_TREE" ]]; then
146
+ # Empty-tree baseline: everything in HEAD is "new".
147
+ RANGE="${EMPTY_TREE} HEAD"
148
+ fi
149
+
150
+ # Parse NUL-terminated name-status output.
151
+ # Format: <STATUS>\0<path>[\0<new_path>] where STATUS can be A/M/D/Rnn/Cnn.
152
+ # We collapse to the post-rename path so downstream classification matches
153
+ # the current file layout.
154
+ FILES="$(
155
+ git diff --name-status -M90% -z \
156
+ -- "$SCOPE_PATH" ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!yarn.lock' \
157
+ ':!.github/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/*.stories.*' \
158
+ $RANGE 2>/dev/null |
159
+ awk -v RS='\0' '
160
+ BEGIN { status = "" }
161
+ {
162
+ if (status == "") { status = $0; next }
163
+ # Rename/copy has two path fields; we want the second (post-rename).
164
+ if (status ~ /^R/ || status ~ /^C/) {
165
+ if (wanted == "") { wanted = 1; next }
166
+ print; status = ""; wanted = ""
167
+ } else {
168
+ print; status = ""
169
+ }
170
+ }
171
+ ' | sort -u
172
+ )"
173
+
174
+ # --first-parent keeps one entry per merged PR (trunk-based). Combine
175
+ # with --cherry-pick --right-only to dedupe back-ports / rebased copies.
176
+ if [[ "$RANGE_START" == "$EMPTY_TREE" ]]; then
177
+ COMMITS="$(git log --first-parent --pretty=format:'%H|%s|%an|%aI' HEAD 2>/dev/null || true)"
178
+ else
179
+ COMMITS="$(git log --first-parent --cherry-pick --right-only --no-merges \
180
+ --pretty=format:'%H|%s|%an|%aI' "${RANGE_START}...HEAD" 2>/dev/null || true)"
181
+ fi
28
182
  MODE="sha-range"
29
183
  fi
30
184
 
@@ -32,7 +186,7 @@ declare -A CLASSIFIED
32
186
  while IFS= read -r p; do
33
187
  [[ -z "$p" ]] && continue
34
188
  case "$p" in
35
- tailwind.config.*|*.tokens.json|styles/tokens.css|styles/theme.css) CLASSIFIED[tokens]+="$p,";;
189
+ tailwind.config.*|*.tokens.json|*.tokens|styles/tokens.css|styles/theme.css) CLASSIFIED[tokens]+="$p,";;
36
190
  components/*|src/components/*|app/_components/*) CLASSIFIED[components]+="$p,";;
37
191
  app/*/page.*|app/page.*|app/*/route.*|app/route.*|pages/*|src/pages/*|app/routes/*|src/routes/*) CLASSIFIED[routes]+="$p,";;
38
192
  public/*|src/assets/*|assets/*) CLASSIFIED[imagery]+="$p,";;
@@ -43,7 +197,8 @@ while IFS= read -r p; do
43
197
  esac
44
198
  done <<< "$FILES"
45
199
 
46
- jq -Rn --arg mode "$MODE" --arg range_start "$RANGE_START" --arg last_iso "$LAST_ISO" \
200
+ jq -Rn --arg mode "$MODE" --arg range_start "${RANGE_START:-}" --arg last_iso "$LAST_ISO" \
201
+ --arg app_path "$APP_PATH" \
47
202
  --arg tokens "${CLASSIFIED[tokens]:-}" --arg components "${CLASSIFIED[components]:-}" \
48
203
  --arg routes "${CLASSIFIED[routes]:-}" --arg imagery "${CLASSIFIED[imagery]:-}" \
49
204
  --arg deps "${CLASSIFIED[deps]:-}" --arg theory "${CLASSIFIED[theory]:-}" \
@@ -51,7 +206,8 @@ jq -Rn --arg mode "$MODE" --arg range_start "$RANGE_START" --arg last_iso "$LAST
51
206
  --argjson files "$(printf '%s\n' "$FILES" | jq -R . | jq -s .)" \
52
207
  --argjson commits "$(printf '%s\n' "$COMMITS" | jq -R . | jq -s .)" '
53
208
  def tolist(s): s | split(",") | map(select(length>0));
54
- {mode:$mode, range_start:$range_start, since_iso:$last_iso,
209
+ {app_path:$app_path,
210
+ mode:$mode, range_start:$range_start, since_iso:$last_iso,
55
211
  commits:$commits, files:$files,
56
212
  classified: {
57
213
  tokens: tolist($tokens), components: tolist($components),
@@ -1,6 +1,26 @@
1
1
  #!/usr/bin/env bash
2
+ # discover-routes.sh — discover route URLs for the detected framework.
3
+ #
4
+ # Dynamic routes (Next `[slug]` / `[[...foo]]` / `[...bar]`, Remix `$slug`,
5
+ # React-Router/Vue `:slug`) are emitted with a `@fixture-<id>` suffix so
6
+ # the route map has a stable identity across audits (artifact §2.7 + §7:
7
+ # "/posts/[id]@fixture-post-123"). Fixtures are resolved via:
8
+ #
9
+ # 1. Sibling file: <route-dir>/<name>.fixture.json|ts (array of IDs or
10
+ # objects with {id|slug|key}).
11
+ # 2. Nearby directory: <route-dir>/fixtures/*.json.
12
+ # 3. Env override: $SUPER_DESIGN_FIXTURES — a JSON object mapping the
13
+ # raw pattern (e.g. "/posts/[slug]") to an array of fixture IDs.
14
+ # 4. Placeholder fallback: `@fixture-default` (with stderr warning).
15
+ #
16
+ # Consumers (hash-pages.sh, sd-audit) MUST strip the `@fixture-<id>`
17
+ # suffix before navigating; the suffix is identity-only.
18
+ #
19
+ # Output: JSON array of URL strings.
2
20
  set -euo pipefail
3
21
 
22
+ log() { printf '[discover-routes] %s\n' "$*" >&2; }
23
+
4
24
  detect_framework() {
5
25
  if [[ -f next.config.js || -f next.config.ts || -f next.config.mjs ]]; then echo "next"
6
26
  elif [[ -f remix.config.js || -d app/routes ]]; then echo "remix"
@@ -13,6 +33,96 @@ detect_framework() {
13
33
  else echo "unknown"; fi
14
34
  }
15
35
 
36
+ # is_dynamic <url> → 0 if URL contains a dynamic segment, else 1.
37
+ is_dynamic() {
38
+ local u="$1"
39
+ # Next-style: [foo], [[foo]], [...foo], [[...foo]]
40
+ [[ "$u" == *"["*"]"* ]] && return 0
41
+ # Remix-style: $foo
42
+ [[ "$u" == *"$"* ]] && return 0
43
+ # RR/Vue-style: :foo
44
+ [[ "$u" == *":"* ]] && return 0
45
+ return 1
46
+ }
47
+
48
+ # Look for a fixtures source for a given route URL and emit IDs one per
49
+ # line. Echo `__DEFAULT__` if nothing found.
50
+ resolve_fixture_ids() {
51
+ local url="$1"
52
+ local segment
53
+ # Strip leading / and use the last dynamic-bearing segment to find a file.
54
+ # Rough heuristic: pick the last path component that is a bare identifier
55
+ # (strip brackets/dollar/colon) to look up fixtures/<name>.*.
56
+ segment="$(printf '%s' "$url" \
57
+ | sed -E 's|^/||' \
58
+ | tr '/' '\n' \
59
+ | grep -E '^(\[\[?\.\.\.?.+\]\]?|\[.+\]|\$.+|:.+)$' \
60
+ | tail -1 \
61
+ | sed -E 's|^\[\[?\.?\.?\.?||; s|\]\]?$||; s|^\$||; s|^:||')"
62
+ [[ -z "$segment" ]] && { echo "__DEFAULT__"; return; }
63
+
64
+ # 1. Env override.
65
+ if [[ -n "${SUPER_DESIGN_FIXTURES:-}" ]]; then
66
+ local ids
67
+ ids="$(printf '%s' "$SUPER_DESIGN_FIXTURES" \
68
+ | jq -r --arg k "$url" '.[$k][]? // empty' 2>/dev/null || true)"
69
+ if [[ -n "$ids" ]]; then
70
+ printf '%s\n' "$ids"; return
71
+ fi
72
+ fi
73
+
74
+ # 2. Sibling / nearby fixture files. Search in common locations.
75
+ local candidate ids=""
76
+ for candidate in \
77
+ "${segment}.fixture.json" \
78
+ "fixtures/${segment}.json" \
79
+ "tests/fixtures/${segment}.json" \
80
+ "__fixtures__/${segment}.json" \
81
+ ".super-design/fixtures/${segment}.json"; do
82
+ local found
83
+ found="$(find . -type f -name "$(basename "$candidate")" 2>/dev/null \
84
+ | grep -F "/$candidate" | head -1 || true)"
85
+ if [[ -n "$found" && -f "$found" ]]; then
86
+ ids="$(jq -r '
87
+ if type == "array" then
88
+ .[] | if type == "object" then (.id // .slug // .key // empty) else . end
89
+ elif type == "object" and (.fixtures|type=="array") then
90
+ .fixtures[] | if type=="object" then (.id // .slug // .key // empty) else . end
91
+ else empty end
92
+ ' "$found" 2>/dev/null | head -5 || true)"
93
+ if [[ -n "$ids" ]]; then
94
+ printf '%s\n' "$ids"; return
95
+ fi
96
+ fi
97
+ done
98
+
99
+ # 3. Give up → default placeholder with warning.
100
+ log "no fixtures for $url; using @fixture-default"
101
+ echo "__DEFAULT__"
102
+ }
103
+
104
+ # suffix_dynamic_routes: read raw URLs on stdin, write suffixed URLs on
105
+ # stdout. Static routes pass through untouched.
106
+ suffix_dynamic_routes() {
107
+ local url ids id
108
+ while IFS= read -r url; do
109
+ [[ -z "$url" ]] && continue
110
+ if is_dynamic "$url"; then
111
+ ids="$(resolve_fixture_ids "$url")"
112
+ if [[ "$ids" == "__DEFAULT__" ]]; then
113
+ printf '%s@fixture-default\n' "$url"
114
+ else
115
+ while IFS= read -r id; do
116
+ [[ -z "$id" ]] && continue
117
+ printf '%s@fixture-%s\n' "$url" "$id"
118
+ done <<<"$ids"
119
+ fi
120
+ else
121
+ printf '%s\n' "$url"
122
+ fi
123
+ done
124
+ }
125
+
16
126
  FW="$(detect_framework)"
17
127
  case "$FW" in
18
128
  next)
@@ -22,24 +132,28 @@ case "$FW" in
22
132
  PG="$(find pages src/pages -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.jsx' -o -name '*.js' -o -name '*.md' -o -name '*.mdx' \) 2>/dev/null \
23
133
  | grep -vE '(^|/)(pages|src/pages)/(_app|_document|_error|404|500|api/)' \
24
134
  | sed -E 's|(^|/)(pages|src/pages)/||; s|\.(tsx|ts|jsx|js|md|mdx)$||; s|index$||' | sort -u || true)"
25
- printf '%s\n%s\n' "$APP" "$PG" | awk 'NF' | sed -E 's|^|/|; s|//|/|g' | sort -u | jq -Rn '[inputs]';;
135
+ printf '%s\n%s\n' "$APP" "$PG" | awk 'NF' | sed -E 's|^|/|; s|//|/|g' | sort -u \
136
+ | suffix_dynamic_routes | jq -Rn '[inputs]';;
26
137
  sveltekit)
27
138
  find src/routes -type f -name '+page.svelte' 2>/dev/null \
28
139
  | sed -E 's|^src/routes||; s|/\+page\.svelte$||; s|\([^)]+\)/||g' \
29
- | awk 'NF==0 {print "/"; next} {print}' | sort -u | jq -Rn '[inputs]';;
140
+ | awk 'NF==0 {print "/"; next} {print}' | sort -u \
141
+ | suffix_dynamic_routes | jq -Rn '[inputs]';;
30
142
  astro)
31
143
  find src/pages -type f \( -name '*.astro' -o -name '*.md' -o -name '*.mdx' \) 2>/dev/null \
32
- | sed -E 's|^src/pages||; s|\.(astro|md|mdx)$||; s|/index$||' | sort -u | jq -Rn '[inputs]';;
144
+ | sed -E 's|^src/pages||; s|\.(astro|md|mdx)$||; s|/index$||' | sort -u \
145
+ | suffix_dynamic_routes | jq -Rn '[inputs]';;
33
146
  nuxt)
34
147
  find pages app/pages -type f -name '*.vue' 2>/dev/null \
35
- | sed -E 's|^(app/)?pages||; s|\.vue$||; s|/index$||' | sort -u | jq -Rn '[inputs]';;
148
+ | sed -E 's|^(app/)?pages||; s|\.vue$||; s|/index$||' | sort -u \
149
+ | suffix_dynamic_routes | jq -Rn '[inputs]';;
36
150
  remix)
37
151
  find app/routes -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.jsx' -o -name '*.js' -o -name '*.md' -o -name '*.mdx' \) 2>/dev/null \
38
152
  | sed -E 's|^app/routes/||; s|\.(tsx|ts|jsx|js|md|mdx)$||; s|\.|/|g; s|_index$||; s|^|/|' \
39
- | sort -u | jq -Rn '[inputs]';;
153
+ | sort -u | suffix_dynamic_routes | jq -Rn '[inputs]';;
40
154
  solid-start)
41
155
  find src/routes -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.jsx' -o -name '*.js' \) 2>/dev/null \
42
156
  | sed -E 's|^src/routes||; s|\.(tsx|ts|jsx|js)$||; s|/index$||; s|\([^)]+\)/||g' \
43
- | sort -u | jq -Rn '[inputs]';;
157
+ | sort -u | suffix_dynamic_routes | jq -Rn '[inputs]';;
44
158
  *) echo '[]';;
45
159
  esac