Skill: brasco05/safe-change (version 1.0.2)

--- name: safe-change description: "Map blast radius before shipping — run when editing a service, controller, hook, or shared utility to surface all importers, affected API routes, test gaps, ENV vars, and recent migrations." --- # Safe Change Know exactly what breaks before you touch it. Safe Change maps the blast radius of any code change — importers, API routes, test coverage, ENV vars, database migrations — then gives you a risk score and waits for your go/no-go before running the verify gate. It is the proactive companion to deep-debugging: **catch impact before the bug ships**, not after. --- ## Quick Reference | Situation | Action | |-----------|--------| | About to edit a shared service | → Run `scan-impact.mjs` on the target file first | | Risk score is High | → Stop, read the impact report, get explicit go/no-go | | Test gap detected (`gap: true`) | → Write tests before changing, not after | | Recent migrations found | → Confirm migration compatibility before deploying | | All checks pass | → Run `verify-gate.sh` to confirm build integrity | | Risk score is Low, tests green | → Proceed, run verify gate at end | --- ## When to Use Activate this skill whenever: - You are about to **rename or extract** a function/class used across multiple files - You are changing the **signature** of a service method - You are modifying a **NestJS controller** or **Next.js API route** - You are touching a file that is imported by more than 3 other files - You are changing code that reads from `process.env` - There is an **active DB migration** in the migrations folder - A teammate asks "is it safe to change X?" Do not skip this step for "small" changes. Most production incidents start as changes that felt small. --- ## How It Works — 6 Phases ### Phase 1 — Detect Stack The agent reads `package.json` at the project root to determine: - Is this NestJS, Next.js, or generic TypeScript? - Are both frameworks present (monorepo)? ### Phase 2 — Build Impact Map Run `scripts/scan-impact.mjs <target-file>` from the project root. The script uses regex-based static analysis (no AST compiler, zero install friction) to collect: | Dimension | What is collected | |-----------|-------------------| | **Importers** | All `.ts`/`.tsx` files that import the target | | **API Routes** | NestJS `@Controller` + HTTP verb decorators; Next.js `app/api/**/route.ts` | | **Tests** | Spec/test files that import the target; gap flag when none exist | | **ENV vars** | All `process.env.X` references in the target file | | **DB migrations** | Files in `migrations/` modified in the last 7 days | ### Phase 3 — Risk Score Heuristic scoring (see table below). The agent reads the JSON output and renders it as a human-readable report. | Score | Conditions | |-------|------------| | **Low** | ≤2 importers, no API routes, tests exist, no ENV vars, no recent migrations | | **Medium** | 3–7 importers OR 1–2 routes OR test gap OR ENV vars present | | **High** | ≥8 importers OR ≥3 routes OR test gap + ENV vars + recent migration | ### Phase 4 — Render Report The agent formats the JSON from `scan-impact.mjs` into a Markdown report (see Output Format below). It presents the report and **explicitly states the risk score** at the top. ### Phase 5 — Checkpoint (mandatory) After presenting the report the agent **must pause and ask**: > "Risk score is [Low/Medium/High]. Do you want to proceed with this change? (yes / no / adjust scope)" Do not proceed until the user confirms. This is the core safety gate. ### Phase 6 — Verify Gate After the change is made, run `scripts/verify-gate.sh` from the project root. The gate runs in order: 1. `tsc --noEmit` — type-check 2. `npm run lint` (if script exists) 3. `npm test` (if script exists) 4. `npm run build` (if script exists) Stops on first failure. Color-coded output. Non-zero exit on failure. --- ## Output Format The agent renders the JSON from `scan-impact.mjs` as: ```markdown ## Safe Change Report — src/notifications/notifications.service.ts **Risk Score: MEDIUM** Risk factors: 5 importers, 1 API route, test coverage exists ### Importers (5) - src/users/users.service.ts - src/appointments/appointments.service.ts - src/billing/billing.service.ts - src/reports/reports.service.ts - src/audit/audit.service.ts ### API Routes Affected | Controller | Endpoints | |------------|-----------| | NotificationsController | POST /notifications, GET /notifications | ### Test Coverage - src/notifications/notifications.service.spec.ts ✓ - Gap: no ### ENV Variables Referenced - SMTP_HOST - SMTP_USER - SMTP_PASS ### Recent Migrations (last 7 days) - None --- **Checkpoint:** Do you want to proceed with this change? (yes / no / adjust scope) ``` --- ## Limitations See `references/limitations.md` for the full list. Key constraints: - Regex-based: dynamic imports (`import(path)`) are not detected - Re-exports through barrel files (`index.ts`) may undercount importers - Decorator aliases (custom `@Route()` wrapping `@Controller`) are not detected - TypeScript only — no Python, Go, Rust adapters in v0.1 --- ## Companion Skills - **deep-debugging** — use after a bug ships; safe-change is what you run before - **self-improving-agent** — log the impact report as a learning when a change causes an incident --- ## Scripts | Script | Purpose | |--------|---------| | `scripts/scan-impact.mjs` | Builds impact map, outputs JSON | | `scripts/verify-gate.sh` | Runs tsc → lint → test → build in sequence | --- ## File Structure ``` safe-change/ ├── SKILL.md # This file ├── README.md # Marketing overview ├── package.json # ClawHub metadata ├── scripts/ │ ├── scan-impact.mjs # Impact analyzer (Node ESM, no deps) │ └── verify-gate.sh # Verify gate (bash) ├── references/ │ ├── example-impact-report.md # Full SMTP swap example │ ├── usage.md # How agent invokes the scripts │ └── limitations.md # Known limitations + trade-offs └── assets/ └── SKILL-TEMPLATE.md # Template for creating similar skills ```

path: README.md
content: # 🛡️ Safe Change

**Map the blast radius before an AI agent edits code.**

Safe Change is the “look left and right before crossing” skill for code changes. It scans the target file, finds importers, routes, tests, ENV variables, and migration risk, then forces a go/no-go checkpoint before anything is modified.

## What It Prevents

- Renaming a shared method and breaking hidden callers
- Editing an auth/payment/data path without knowing affected routes
- Shipping a refactor with no tests covering the changed path
- Letting an agent make a “small” change that touches half the system

## What It Does

- Builds an **Impact Map** for the target file
- Calculates **Low / Medium / High** risk with concrete reasons
- Shows **test gaps** and likely verification commands
- Flags **ENV/config/migration** risk
- Pauses for explicit **go / shrink scope / abort** before editing
- Runs a post-change verify gate: typecheck → lint → test → build

## Quick Example

```bash
node scripts/scan-impact.mjs src/auth/auth.service.ts
```

Condensed report:

```json
{
  "target": "src/auth/auth.service.ts",
  "imports": { "count": 7 },
  "routes": { "endpoints": ["POST /auth/login", "GET /auth/me"] },
  "tests": { "count": 0, "gap": true },
  "env_vars": ["JWT_SECRET"],
  "risk_score": "high",
  "risk_factors": ["7 importers", "auth routes", "no direct tests", "JWT env"]
}
```

Agent response should be:

```text
Risk: HIGH.
Recommendation: shrink scope or add/locate tests before editing.
Go / shrink / abort?
```

## Best Targets

- NestJS services/controllers/guards
- Next.js route handlers/server actions/hooks
- Shared TypeScript utilities
- Auth, billing, data import/export, migrations

## Not For

- Pure text/README edits
- Obvious typo fixes
- Throwaway prototypes

## Companion Skills

- **coding-pipeline** — wraps the full plan/code/validate/debug loop
- **deep-debugging** — use after a bug already exists

Safe Change is proactive. Run it before the mess.

---

*by brasco05 · read-only impact scanning, zero dependencies*


path: assets/SKILL-TEMPLATE.md
content: # Skill Template — safe-change Style

Template for creating impact-analysis companion skills in the safe-change style.
Copy and customize for new stack support (Python, Go, etc.) or new analysis dimensions.

---

## SKILL.md Template

```markdown
---
name: safe-change-<stack>
description: "Map blast radius before shipping on <stack> — run when editing a shared module, route handler, or config-dependent file."
---

# Safe Change — <Stack>

[One sentence: what problem this solves and for which stack]

## Quick Reference

| Situation | Action |
|-----------|--------|
| Editing a shared module | → Run `scan-impact.mjs` first |
| Risk score is High | → Stop, read report, get go/no-go |
| Test gap detected | → Write tests before changing |
| All checks pass | → Run verify gate |

## When to Use

[2-3 bullets: specific trigger conditions for this stack]

## How It Works — 6 Phases

### Phase 1 — Detect Stack
[Stack detection logic]

### Phase 2 — Build Impact Map
[What dimensions are collected and how]

### Phase 3 — Risk Score
[Heuristic table: Low / Medium / High conditions]

### Phase 4 — Render Report
[Output format description]

### Phase 5 — Checkpoint (mandatory)
[Pause instruction — this is the core gate]

### Phase 6 — Verify Gate
[What commands run in which order]

## Output Format

[Markdown report template with placeholders]

## Limitations

See `references/limitations.md`.

## Companion Skills

- safe-change (TypeScript/NestJS/Next.js) — the original
- deep-debugging — use after a bug ships
```

---

## scan-impact.mjs Extension Pattern

To add a new analysis dimension to `scan-impact.mjs`:

```javascript
// 1. Add a new async scanner function (max 40 lines)
async function scanNewDimension(root, targetRel) {
  // Read files, apply regex, return structured data
  return { count: 0, items: [] };
}

// 2. Call it in main()
const newDimension = await scanNewDimension(projectRoot, targetRelative);

// 3. Add to output object
const output = {
  // ... existing fields
  new_dimension: newDimension,
};

// 4. Include in risk score if relevant
// Update calculateRiskScore() to use the new data
```

Rules for new scanner functions:
- No external dependencies (Node stdlib only)
- Read-only (never write files)
- Must handle missing directories gracefully (try/catch or existsSync)
- Max 40 lines per function
- Return structured data (not raw strings)

---

## Risk Score Extension Pattern

Current thresholds in `calculateRiskScore()`:

```javascript
// High
importerCount >= 8 || endpointCount >= 3 || (testGap && envVarCount > 0 && recentMigrationCount > 0)

// Low
importerCount <= 2 && endpointCount === 0 && !testGap && envVarCount === 0 && recentMigrationCount === 0

// Medium: everything else
```

To add a new signal:

```javascript
// Add to the High condition
|| newDimension.criticalCount > 0

// Add to the Low condition
&& newDimension.count === 0

// Add to factors array
if (newDimension.count > 0) factors.push(`${newDimension.count} <label>`);
```

---

## verify-gate.sh Extension Pattern

To add a new check to the verify gate:

```bash
# After the existing checks, before print_success

# ---- Check N: Custom Check --------------------------------------------
if has_npm_script "custom-check"; then
  run_check "Custom Check (npm run custom-check)" "npm run custom-check"
else
  skip_check "Custom Check" "no 'custom-check' script in package.json"
fi
```

The `run_check` function handles:
- Printing the label and command
- Running in the project root
- Colored pass/fail output
- Stopping on failure (via `exit 1`)

---

## File Naming Conventions

```
safe-change-<stack>/
├── SKILL.md
├── README.md
├── package.json              # "name": "safe-change-<stack>"
├── scripts/
│   ├── scan-impact.mjs       # Main analyzer
│   └── verify-gate.sh        # Verify gate
├── references/
│   ├── example-impact-report.md
│   ├── usage.md
│   └── limitations.md
└── assets/
    └── SKILL-TEMPLATE.md     # This file
```

---

## Checklist Before Publishing

- [ ] `scan-impact.mjs --help` runs without error
- [ ] `verify-gate.sh --help` runs without error
- [ ] At least one realistic example in `references/example-impact-report.md`
- [ ] `references/limitations.md` is honest about what is not detected
- [ ] `package.json` has correct `openclaw.skill` and `version`
- [ ] Scripts are read-only (no file writes to source directories)
- [ ] Scripts handle missing directories gracefully
- [ ] Risk score thresholds are documented in SKILL.md


path: assets/banner.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
  <defs>
    <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:#0f172a"/>
      <stop offset="100%" style="stop-color:#042f2e"/>
    </linearGradient>
    <linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
      <stop offset="0%" style="stop-color:#14b8a6"/>
      <stop offset="100%" style="stop-color:#10b981"/>
    </linearGradient>
  </defs>

  <!-- Background -->
  <rect width="1200" height="630" fill="url(#bg)"/>

  <!-- Subtle grid -->
  <g opacity="0.05" stroke="#14b8a6" stroke-width="1">
    <line x1="0" y1="105" x2="1200" y2="105"/>
    <line x1="0" y1="210" x2="1200" y2="210"/>
    <line x1="0" y1="315" x2="1200" y2="315"/>
    <line x1="0" y1="420" x2="1200" y2="420"/>
    <line x1="0" y1="525" x2="1200" y2="525"/>
    <line x1="150" y1="0" x2="150" y2="630"/>
    <line x1="300" y1="0" x2="300" y2="630"/>
    <line x1="450" y1="0" x2="450" y2="630"/>
    <line x1="600" y1="0" x2="600" y2="630"/>
    <line x1="750" y1="0" x2="750" y2="630"/>
    <line x1="900" y1="0" x2="900" y2="630"/>
    <line x1="1050" y1="0" x2="1050" y2="630"/>
  </g>

  <!-- Glow circle -->
  <circle cx="600" cy="280" r="200" fill="#14b8a6" opacity="0.05"/>

  <!-- Shield icon (SVG primitives) -->
  <g transform="translate(560, 128)">
    <!-- Shield outer path -->
    <path d="M40 0 L80 14 L80 46 Q80 70 40 84 Q0 70 0 46 L0 14 Z" fill="none" stroke="url(#accent)" stroke-width="3" stroke-linejoin="round"/>
    <!-- Shield inner check mark -->
    <polyline points="20,44 34,58 60,30" fill="none" stroke="#10b981" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
  </g>

  <!-- Title -->
  <text x="600" y="310" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="56" font-weight="900" fill="white" letter-spacing="-1">Safe Change</text>

  <!-- Accent line -->
  <rect x="480" y="330" width="240" height="4" rx="2" fill="url(#accent)"/>

  <!-- Subtitle -->
  <text x="600" y="390" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" fill="#94a3b8">Map blast radius before you ship</text>

  <!-- Tags -->
  <g transform="translate(295, 440)">
    <rect width="150" height="36" rx="18" fill="#0f2d2b" stroke="#14b8a6" stroke-width="1" opacity="0.9"/>
    <text x="75" y="23" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#5eead4">impact map</text>
  </g>
  <g transform="translate(462, 440)">
    <rect width="140" height="36" rx="18" fill="#0f2d2b" stroke="#14b8a6" stroke-width="1" opacity="0.9"/>
    <text x="70" y="23" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#5eead4">risk score</text>
  </g>
  <g transform="translate(619, 440)">
    <rect width="160" height="36" rx="18" fill="#0f2d2b" stroke="#14b8a6" stroke-width="1" opacity="0.9"/>
    <text x="80" y="23" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#5eead4">verify gate</text>
  </g>
  <g transform="translate(796, 440)">
    <rect width="120" height="36" rx="18" fill="#0f2d2b" stroke="#14b8a6" stroke-width="1" opacity="0.9"/>
    <text x="60" y="23" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#5eead4">zero deps</text>
  </g>

  <!-- Bottom author -->
  <text x="600" y="590" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#475569">by brasco05 · ClawHub</text>
</svg>


path: package.json
content: {
  "name": "safe-change",
  "version": "1.0.2",
  "description": "Map code-change blast radius before editing: importers, API routes, tests, env vars, migrations, and verification risk.",
  "author": "brasco05",
  "license": "MIT",
  "keywords": [
    "impact-analysis",
    "safe-change",
    "refactoring",
    "nestjs",
    "nextjs",
    "typescript",
    "risk-score",
    "code-review",
    "pre-commit",
    "deep-debugging",
    "blast-radius",
    "agent-safety"
  ]
}


path: references/example-impact-report.md
content: # Example Impact Report — SMTP Provider Swap

**Scenario:** You are swapping the SMTP provider in a Caresys-style NestJS app. The target file is `src/notifications/notifications.service.ts`. It currently uses Nodemailer with SendGrid credentials. You want to switch to Resend.

This is a realistic "feels small" change that actually touches 5 modules and a live API route.

---

## Step 1 — Run the Scanner

```bash
node skills/safe-change/scripts/scan-impact.mjs src/notifications/notifications.service.ts
```

---

## Step 2 — Raw JSON Output

```json
{
  "target": "src/notifications/notifications.service.ts",
  "stack": {
    "framework": "nestjs",
    "hasNextJs": false
  },
  "imports": {
    "importers": [
      "src/users/users.service.ts",
      "src/appointments/appointments.service.ts",
      "src/billing/billing.service.ts",
      "src/reports/reports.service.ts",
      "src/audit/audit.service.ts"
    ],
    "count": 5
  },
  "routes": {
    "controllers": ["NotificationsController"],
    "endpoints": [
      "POST /notifications",
      "GET /notifications"
    ],
    "next_routes": []
  },
  "tests": {
    "covering": [
      "src/notifications/notifications.service.spec.ts"
    ],
    "count": 1,
    "gap": false
  },
  "env_vars": [
    "SMTP_HOST",
    "SMTP_PASS",
    "SMTP_USER"
  ],
  "migrations": {
    "recent_count": 0,
    "recent_files": []
  },
  "risk_score": "medium",
  "risk_factors": [
    "5 importers",
    "1 API route",
    "test coverage exists"
  ]
}
```

---

## Step 3 — Agent Renders Markdown Report

The agent reads the JSON and renders:

```markdown
## Safe Change Report — src/notifications/notifications.service.ts

**Risk Score: MEDIUM**
Risk factors: 5 importers, 1 API route, test coverage exists

### Importers (5)
These files call into NotificationsService. A breaking change here affects all of them.

- src/users/users.service.ts
- src/appointments/appointments.service.ts
- src/billing/billing.service.ts
- src/reports/reports.service.ts
- src/audit/audit.service.ts

### API Routes Affected

| Controller | Endpoints |
|------------|-----------|
| NotificationsController | POST /notifications |
| NotificationsController | GET /notifications |

The POST endpoint is called by the billing flow on invoice creation.
The GET endpoint is used by the frontend notification bell.

### Test Coverage

- src/notifications/notifications.service.spec.ts ✓
- Gap: no

One spec file exists. Before swapping providers, verify it mocks at the
interface level (not the Nodemailer transport directly) — otherwise tests
pass but the new provider is untested.

### ENV Variables Referenced

- SMTP_HOST — must be updated to Resend's SMTP gateway
- SMTP_USER — update to Resend API key (username format differs)
- SMTP_PASS — update to Resend API key value

All three ENV vars must be rotated in Railway (production) and .env.local
before deploying.

### Recent Migrations (last 7 days)

- None — no active schema changes in flight, safe to deploy separately

---

**Checkpoint:** Risk score is MEDIUM. 5 services depend on this file and
3 ENV vars must be rotated. Do you want to proceed? (yes / no / adjust scope)
```

---

## Step 4 — User Says Yes

User confirms: "yes, proceed."

The agent swaps the SMTP provider, updates the ENV var names, adjusts the spec mock.

---

## Step 5 — Verify Gate

```bash
bash skills/safe-change/scripts/verify-gate.sh
```

Output:

```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Safe Change — Verify Gate
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Root: /Users/numan/Desktop/Projekte/caresys/backend

▶ TypeScript (tsc --noEmit)
  node_modules/.bin/tsc --noEmit

✓ TypeScript passed

▶ Lint (npm run lint)
  npm run lint

✓ Lint passed

▶ Tests (npm test)
  npm test

✓ Tests passed

▶ Build (npm run build)
  npm run build

✓ Build passed

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  All checks passed. Safe to proceed.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```

---

## What Would Have Gone Wrong Without safe-change

Without running the scanner first:

1. **ENV vars not rotated** — production uses old SMTP_HOST, emails fail silently
2. **NotificationsController tests pass locally** but mock the wrong transport layer
3. **billing.service.ts** now imports a changed interface signature — TypeScript would catch this, but only at build time, not before starting
4. **The spec file** mocked Nodemailer's `createTransport` — after the swap, the mock is orphaned and tests give a false green

The MEDIUM risk score is the signal: "this isn't risky enough to block, but it's risky enough to review each factor before touching anything."

A HIGH score would have been the signal to stop entirely and reassess scope.

---

## When the Score Would Be HIGH

Modify the scenario: same file, but add:

- `billing.service.ts` also has an active migration for a new `notification_log` table (recent migration +1)
- `notifications.service.ts` has no spec file (test gap)
- 9 other files import it (importer count ≥ 8)

Score jumps to **HIGH**. The agent stops and says:

> "Risk score is HIGH. 9 importers, no test coverage, and an active migration in flight.
> Recommended action: split this into two changes — (1) add tests first, then (2) swap provider after migration is deployed. Do you want to proceed anyway? (yes / no)"


path: references/limitations.md
content: # Limitations — safe-change v0.1

This document is an honest disclosure of what safe-change can and cannot detect. Read it before trusting a "Low" risk score on a complex codebase.

---

## Design Decision: Regex over AST

`scan-impact.mjs` uses regex-based static analysis, not an AST compiler (ts-morph, TypeScript Compiler API, etc.).

**Why:** Zero install friction. ClawHub users can drop the skill folder and run immediately — no `npm install`, no peer dependencies, no version conflicts with the host project's TypeScript version.

**Trade-off:** Regex cannot understand code semantics. It pattern-matches text. This means the categories below are blind spots.

---

## Known Limitations

### 1. Dynamic Imports

**Not detected.**

```typescript
// This will NOT appear in the importer list
const mod = await import(`./services/${name}.service`);
const { NotificationsService } = await import('./notifications/notifications.service');
```

Dynamic imports with string literals are not matched by the current regex patterns. This is the most common false-negative in modern NestJS codebases that use lazy-loaded modules.

**Mitigation:** If you know the codebase uses dynamic imports, run `grep -r "import(" src/` manually and check the results.

---

### 2. Barrel Re-exports (index.ts)

**Undercounts importers.**

```typescript
// src/notifications/index.ts
export { NotificationsService } from './notifications.service';

// src/users/users.service.ts
import { NotificationsService } from '../notifications'; // <-- imports via barrel
```

The scanner checks if `notifications.service.ts` appears in the import string. When the import goes through `../notifications` (a barrel), the scanner does not trace through the barrel — it will miss `users.service.ts` as an importer.

**Mitigation:** Check for `index.ts` files in the target's directory. If one exists, grep for imports of the barrel path manually.

---

### 3. Decorator Aliases

**Not detected.**

```typescript
// A custom decorator wrapping @Controller
@JsonController('/notifications') // from routing-controllers or a custom factory
export class NotificationsController { ... }
```

The scanner looks specifically for `@Controller(`. Custom decorators that wrap `@Controller` internally are not detected. This will produce `routes.controllers: []` even though the file is a controller.

**Mitigation:** Search for `@JsonController`, `@RestController`, or similar aliases manually in your codebase. Add the known aliases to your project's safe-change workflow notes.

---

### 4. Type-only Imports

**May appear as importers but carry no runtime risk.**

```typescript
import type { NotificationsService } from './notifications.service';
```

Type-only imports are included in the importer count. A file that only imports a type will be listed as an importer, but changing the runtime behavior of `notifications.service.ts` will not affect it at runtime — only if the type signature changes.

**Impact:** Risk score may be slightly inflated for heavily type-shared files (e.g., `types.ts`, `interfaces.ts`). This is intentional — conservative is safer.

---

### 5. Re-export Chains

**Not traced.**

```typescript
// a.ts exports b.ts exports c.ts
// If your target is c.ts, the scanner finds direct importers of c.ts only
// Files that import via a.ts → b.ts are not detected
```

Multi-hop re-export chains are not traced. The scanner does one level of import detection.

---

### 6. Path Aliases (tsconfig paths)

**Partially detected.**

```typescript
// tsconfig.json
{ "paths": { "@notifications/*": ["src/notifications/*"] } }

// another-file.ts
import { NotificationsService } from '@notifications/notifications.service'; // <-- may be missed
```

The scanner resolves relative imports well but may miss imports that use `tsconfig` path aliases (`@app/`, `~`, `@/`, etc.). If your project uses path aliases heavily, the importer count may be undercounted.

**Mitigation:** For path-alias-heavy projects, supplement with `grep -r "@notifications" src/`.

---

### 7. JavaScript Files

**Not scanned.**

The scanner only reads `.ts` and `.tsx` files. If your project has `.js` or `.mjs` files that import TypeScript compiled outputs, those are not detected.

---

### 8. Language Coverage

**TypeScript only in v0.1.**

- Python, Go, Rust, Java — not supported
- PHP — not supported
- Vue SFC, Svelte — not supported (`.vue`, `.svelte` files are skipped)

---

### 9. Test Gap False Negatives

**Test factories and shared mocks.**

```typescript
// notifications.spec.ts
import { createMockNotificationsService } from '../testing/factories';
// Does NOT import notifications.service.ts directly
```

If a spec file imports a mock factory instead of the real service, `tests.gap` will be `true` even though test logic exists. This is a conservative false-positive for the gap flag — the skill would rather warn you than miss a real gap.

---

### 10. ENV Vars in Conditionals

**All ENV references are collected, including unreachable ones.**

```typescript
if (process.env.NODE_ENV === 'test') {
  // This ENV var is listed even in production analysis
  this.host = process.env.SMTP_HOST_OVERRIDE;
}
```

The scanner does not evaluate control flow. All `process.env.X` references are included in the list regardless of whether they are reachable at runtime.

---

## Summary Table

| Limitation | Risk | Workaround |
|------------|------|------------|
| Dynamic imports | False negative (undercount) | Manual `grep -r "import("` |
| Barrel re-exports | False negative (undercount) | Check `index.ts` + grep barrel path |
| Decorator aliases | False negative (no routes) | Know your custom decorators |
| Type-only imports | False positive (overcount) | Acceptable — conservative |
| Path aliases | False negative (undercount) | `grep -r "@alias"` |
| Multi-hop re-exports | False negative (undercount) | One-hop only by design |
| Test factories | False positive (gap flag) | Inspect spec file manually |

---

## Planned Improvements (v0.2+)

- `--follow-barrels` flag: trace `index.ts` re-exports one level deep
- Path alias resolution via `tsconfig.json` `paths` field
- `import type` exclusion flag for risk score calculation
- Vue SFC and Svelte support

These are not commitments — they are the known gaps that would most improve accuracy.


path: references/usage.md
content: # Usage Guide — How the Agent Invokes safe-change

This document tells the AI agent exactly when to run each script, what to do with the output, and how to make decisions based on the result.

---

## When to Activate

Activate safe-change before any of the following:

- User says "I want to change / refactor / rename X"
- User asks "is it safe to modify X?"
- Agent is about to edit a file that is not a leaf (no importers) in the dependency graph
- Agent detects that a change touches a file with `@Controller`, `@Injectable`, or `export default` at module level
- User mentions a deploy is planned within the next hour

Skip safe-change for:
- New files with no existing importers
- README / documentation-only changes
- Files clearly marked as private helpers (single-file usage)

---

## Phase 1 — Run the Scanner

```bash
# From the project root
node skills/safe-change/scripts/scan-impact.mjs <target-file>
```

Or with an explicit root (monorepo):

```bash
node skills/safe-change/scripts/scan-impact.mjs src/auth/auth.service.ts --root ./backend
```

**The script is read-only.** It will never modify source files.

Capture the JSON output. Parse it. Move to Phase 2.

---

## Phase 2 — Interpret the JSON

Key fields and what they mean:

| Field | Meaning | Agent action |
|-------|---------|--------------|
| `imports.count` | Number of files that import the target | High count = more blast radius |
| `routes.endpoints` | API routes affected by a controller change | List them explicitly in the report |
| `tests.gap` | `true` if no spec files cover the target | Warn the user — tests first, change second |
| `env_vars` | ENV vars read in the target file | Must be rotated in all environments |
| `migrations.recent_count` | Migrations modified in last 7 days | Flag compatibility risk |
| `risk_score` | `low` / `medium` / `high` | Drives the checkpoint behavior |

---

## Phase 3 — Render the Report

Format the JSON as a Markdown report. Template:

```markdown
## Safe Change Report — <target>

**Risk Score: <RISK_SCORE in uppercase>**
Risk factors: <risk_factors joined by ", ">

### Importers (<count>)
<list each importer on a bullet>

### API Routes Affected
<table: Controller | Endpoints>
(If none: "None detected")

### Test Coverage
<list covering test files>
Gap: <yes/no>

### ENV Variables Referenced
<bullet list, or "None">

### Recent Migrations (last 7 days)
<list recent_files, or "None">

---
**Checkpoint:** <risk message + go/no-go question>
```

---

## Phase 4 — Checkpoint Behavior

The agent MUST pause here. Do not proceed to editing without explicit user confirmation.

### Risk Score: LOW

> "Risk score is LOW — <factors>. Proceeding looks safe. Shall I continue?"

Acceptable to proceed with a brief confirmation (single word "yes" is enough).

### Risk Score: MEDIUM

> "Risk score is MEDIUM — <factors>. Review the importers and ENV vars above before confirming. Do you want to proceed? (yes / no / adjust scope)"

Do not proceed until the user explicitly says yes.

### Risk Score: HIGH

> "Risk score is HIGH — <factors>. Recommendation: split this change into smaller steps OR add missing tests before proceeding. Do you want to proceed anyway? (yes / no / let me split it)"

If the user says "proceed anyway," log the decision and proceed. Do not silently skip this.

---

## Phase 5 — Make the Change

Apply the change the user requested. Normal agent behavior.

---

## Phase 6 — Run the Verify Gate

After the change is applied:

```bash
bash skills/safe-change/scripts/verify-gate.sh
```

Or with a custom root:

```bash
bash skills/safe-change/scripts/verify-gate.sh --root ./backend
```

**Interpret the exit code:**

- Exit 0 — all checks passed, report success to user
- Exit 1 — a check failed. Report which step failed. Do NOT auto-fix silently. Present the error output and ask the user how to proceed.

---

## Monorepo Setup

If the project has separate `backend/` and `frontend/` directories:

```bash
# For a NestJS backend file
node skills/safe-change/scripts/scan-impact.mjs backend/src/users/users.service.ts --root ./backend
bash skills/safe-change/scripts/verify-gate.sh --root ./backend

# For a Next.js frontend file
node skills/safe-change/scripts/scan-impact.mjs frontend/src/components/UserCard.tsx --root ./frontend
bash skills/safe-change/scripts/verify-gate.sh --root ./frontend
```

---

## Piping to Agent Context

If the agent needs to include the raw JSON in a system message:

```bash
node skills/safe-change/scripts/scan-impact.mjs src/auth/auth.service.ts --json
```

The `--json` flag produces identical output but signals scripting intent clearly.

---

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| `target file not found` | Path is relative to wrong directory | Use `--root` to set project root explicitly |
| `importers: []` when you know there are importers | File uses barrel re-exports | Check `index.ts` files; see `limitations.md` |
| `routes.endpoints: []` on a known controller | Custom decorator wrapping `@Controller` | See `limitations.md` — manual verification needed |
| `tests.gap: true` but spec exists | Spec does not import target directly | Spec may use a mock factory — manual verification |


path: scripts/scan-impact.mjs
content: #!/usr/bin/env node
/**
 * scan-impact.mjs — Safe Change Impact Analyzer
 * Analysiert den Blast-Radius einer Zieldatei: Importers, Routes, Tests, ENV, Migrations.
 * Nutzt ausschliesslich Node.js Stdlib + Regex (keine externen Packages).
 * Read-only — modifiziert keine Quelldateien.
 *
 * Usage:
 *   node scan-impact.mjs <target-file> [--root <project-root>] [--json]
 *   node scan-impact.mjs --help
 */

import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, relative, join, dirname } from 'node:path';
import { existsSync } from 'node:fs';

// ---- CLI ---------------------------------------------------------------

const args = process.argv.slice(2);

if (args.includes('--help') || args.length === 0) {
  printHelp();
  process.exit(0);
}

const targetArg = args.find((a) => !a.startsWith('--'));
const rootFlagIdx = args.indexOf('--root');
const projectRoot = rootFlagIdx !== -1 ? resolve(args[rootFlagIdx + 1]) : process.cwd();
const jsonOnly = args.includes('--json');

if (!targetArg) {
  console.error('Error: target file argument required. Run with --help for usage.');
  process.exit(1);
}

const targetFile = resolve(targetArg);

// Sicherstellen dass die Zieldatei existiert
if (!existsSync(targetFile)) {
  console.error(`Error: target file not found: ${targetFile}`);
  process.exit(1);
}

const targetRelative = relative(projectRoot, targetFile);

// ---- Hilfsfunktionen ----------------------------------------------------

/** Rekursives Einlesen aller .ts/.tsx Dateien eines Verzeichnisses */
async function collectTsFiles(dir) {
  /** @type {string[]} */
  const results = [];
  let entries;
  try {
    entries = await readdir(dir, { withFileTypes: true });
  } catch {
    return results;
  }
  for (const entry of entries) {
    // node_modules, .git, dist, .next, out ueberspringen
    if (['node_modules', '.git', 'dist', '.next', 'out', 'build', 'coverage'].includes(entry.name)) continue;
    const full = join(dir, entry.name);
    if (entry.isDirectory()) {
      results.push(...(await collectTsFiles(full)));
    } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
      results.push(full);
    }
  }
  return results;
}

/** Prueft ob eine Datei das Ziel importiert (statische Imports + require) */
function fileImportsTarget(content, targetRel, importerPath) {
  // Normalisierung: Zieldatei ohne Erweiterung fuer flexiblen Abgleich
  const withoutExt = targetRel.replace(/\.(ts|tsx)$/, '');
  const targetBasename = withoutExt.split('/').at(-1) ?? '';
  // Verzeichnis der importierenden Datei (relativ zum Projekt-Root)
  const importerDir = dirname(importerPath);
  // Regex: from '...target...' oder require('...target...')
  const importPattern = /(?:from\s+|require\s*\(\s*)['"]([^'"]+)['"]/g;
  let match;
  while ((match = importPattern.exec(content)) !== null) {
    const imported = match[1];
    // Absoluter Import-Abgleich: endet mit dem relativen Pfad oder vollem Namen
    if (imported.endsWith(withoutExt) || imported.endsWith(targetRel)) return true;

    // Relativer Import-Abgleich: Pfad aufloesen und vergleichen
    if (imported.startsWith('.')) {
      // Relativen Import-Pfad in tatsaechlichen Projektpfad umrechnen
      const resolvedImport = join(importerDir, imported).replace(/\.(ts|tsx)$/, '');
      if (resolvedImport === withoutExt) return true;
      // Normalisierung fuer Windows-Pfade (Backslash)
      if (resolvedImport.replace(/\\/g, '/') === withoutExt) return true;
    }

    // Segment-Abgleich als Fallback fuer Pfad-Alias-Importe (@app/...)
    const normalizedImport = imported.replace(/\.(ts|tsx)$/, '');
    const importBasename = normalizedImport.split('/').at(-1) ?? '';
    if (importBasename === targetBasename) {
      // Basename stimmt ueberein — zusaetzlich letzten Verzeichnis-Abschnitt pruefen
      const targetDir = withoutExt.split('/').slice(-2, -1)[0] ?? '';
      const importDir = normalizedImport.split('/').slice(-2, -1)[0] ?? '';
      if (targetDir && importDir && targetDir === importDir) return true;
    }
  }
  return false;
}

// ---- Stack Detection ---------------------------------------------------

/** Liest package.json und bestimmt das Framework */
async function detectStack(root) {
  const pkgPath = join(root, 'package.json');
  let pkg = {};
  try {
    pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
  } catch {
    return { framework: 'typescript', hasNextJs: false, hasNestJs: false };
  }
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
  const hasNestJs = '@nestjs/core' in deps || '@nestjs/common' in deps;
  const hasNextJs = 'next' in deps;
  const framework = hasNestJs ? 'nestjs' : hasNextJs ? 'nextjs' : 'typescript';
  return { framework, hasNextJs, hasNestJs };
}

// ---- Importer Scan -----------------------------------------------------

/** Findet alle Dateien die das Ziel direkt importieren */
async function findImporters(root, targetRel) {
  const allFiles = await collectTsFiles(root);
  /** @type {string[]} */
  const importers = [];
  for (const file of allFiles) {
    if (resolve(file) === resolve(targetFile)) continue;
    let content;
    try {
      content = await readFile(file, 'utf8');
    } catch {
      continue;
    }
    const importerRelative = relative(root, file);
    if (fileImportsTarget(content, targetRel, importerRelative)) {
      importers.push(importerRelative);
    }
  }
  return importers;
}

// ---- Next.js Route Utilities -------------------------------------------

/**
 * Prueft ob ein Dateipfad eine Next.js App Router route.ts(x) ist.
 * Nur route.ts / route.tsx gelten als API-Routen im App Router.
 */
function isNextJsAppRoute(filePath) {
  return /[/\\]app[/\\].*route\.(ts|tsx)$/.test(filePath);
}

/**
 * Prueft ob ein Dateipfad eine Next.js Pages Router API-Route ist.
 * Muster: pages/api/**\/*.ts(x)
 */
function isNextJsPagesApiRoute(filePath) {
  return /[/\\]pages[/\\]api[/\\].*\.(ts|tsx)$/.test(filePath);
}

/**
 * Leitet die URL aus einem App-Router route.ts-Pfad ab.
 * Regeln:
 *  - Strip alles bis inkl. 'app/'
 *  - Strip abschliessendes 'route.ts(x)'
 *  - Route Groups (marketing) entfernen (Klammern-Segment)
 *  - Dynamic segments [id] -> :id
 *  - Catch-all [...slug] -> *slug
 */
function deriveAppRouterUrl(absolutePath, root) {
  // Relativen Pfad vom Root berechnen, Backslashes normalisieren
  const rel = relative(root, absolutePath).replace(/\\/g, '/');
  // Alles bis einschliesslich 'app/' entfernen (auch 'src/app/')
  const afterApp = rel.replace(/^.*?app\//, '');
  // Dateiname 'route.ts' / 'route.tsx' entfernen
  const withoutFile = afterApp.replace(/\/?route\.(ts|tsx)$/, '');
  // Verzeichnis-Segmente aufteilen
  const segments = withoutFile.split('/').filter(Boolean);
  // Transformation pro Segment
  const urlSegments = segments
    .filter((seg) => !/^\(.*\)$/.test(seg))           // Route Groups entfernen
    .map((seg) => {
      if (/^\[{3}/.test(seg)) return '*' + seg.replace(/\[{3}\.{3}(\w+)\]{3}/, '$1'); // [...slug]
      if (/^\[{2}/.test(seg)) return ':' + seg.replace(/\[{2}(\w+)\]{2}/, '$1');      // [[id]] optional
      if (/^\[/.test(seg))    return ':' + seg.replace(/\[(\w+)\]/, '$1');             // [id]
      return seg;
    });
  return '/' + urlSegments.join('/');
}

/**
 * Leitet die URL aus einem Pages-Router API-Pfad ab.
 * Muster: pages/api/users/[id].ts -> /api/users/:id
 */
function derivePagesApiUrl(absolutePath, root) {
  const rel = relative(root, absolutePath).replace(/\\/g, '/');
  const afterPages = rel.replace(/^.*?pages\//, '');
  // Erweiterung entfernen
  const withoutExt = afterPages.replace(/\.(ts|tsx)$/, '');
  const segments = withoutExt.split('/').filter(Boolean);
  const urlSegments = segments.map((seg) => {
    if (/^\[\.{3}/.test(seg)) return '*' + seg.replace(/\[\.{3}(\w+)\]/, '$1');
    if (/^\[/.test(seg))      return ':' + seg.replace(/\[(\w+)\]/, '$1');
    return seg;
  });
  return '/' + urlSegments.join('/');
}

/** HTTP-Methoden aus einem Next.js route.ts-Dateiinhalt extrahieren */
function extractNextJsMethods(content) {
  const HTTP_METHODS = 'GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS';
  const methods = new Set();

  // export async function GET(...) und export function GET(...)
  const funcPattern = new RegExp(`export\\s+(?:async\\s+)?function\\s+(${HTTP_METHODS})\\b`, 'g');
  let m;
  while ((m = funcPattern.exec(content)) !== null) methods.add(m[1]);

  // export const GET = ... und export const GET: NextRouteHandler = ...
  const constPattern = new RegExp(`export\\s+const\\s+(${HTTP_METHODS})\\s*[=:]`, 'g');
  while ((m = constPattern.exec(content)) !== null) methods.add(m[1]);

  // export { ..., GET, ... } — best-effort re-export
  const reExportPattern = /export\s*\{([^}]+)\}/g;
  while ((m = reExportPattern.exec(content)) !== null) {
    const names = m[1].split(',').map((n) => n.trim().split(/\s+as\s+/)[0].trim());
    for (const name of names) {
      if (/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/.test(name)) methods.add(name);
    }
  }

  return Array.from(methods);
}

/** Alle App-Router route.ts(x)-Dateien im Projekt aufsammeln (project-wide map) */
async function collectAllNextRoutes(root) {
  const allFiles = await collectTsFiles(root);
  return allFiles
    .filter((f) => isNextJsAppRoute(f))
    .map((f) => relative(root, f).replace(/\\/g, '/'));
}

// ---- Route Detection ---------------------------------------------------

/** Extrahiert NestJS Controller-Namen und HTTP-Endpoints aus einer Datei */
function extractNestJsRoutes(content, filePath) {
  /** @type {string[]} */
  const controllers = [];
  /** @type {string[]} */
  const endpoints = [];

  // @Controller('prefix') oder @Controller()
  const controllerMatch = content.match(/@Controller\(['"]?([^'")\s]*)['"]?\)/);
  if (controllerMatch) {
    // Klassenname nach dem Decorator
    const classMatch = content.match(/@Controller[^]*?class\s+(\w+)/);
    const controllerName = classMatch ? classMatch[1] : 'UnknownController';
    controllers.push(controllerName);
    const prefix = controllerMatch[1] || '';

    // HTTP-Verb Decorators: @Get, @Post, @Put, @Delete, @Patch
    const verbPattern = /@(Get|Post|Put|Delete|Patch)\(['"]?([^'")\s]*)['"]?\)/g;
    let match;
    while ((match = verbPattern.exec(content)) !== null) {
      const method = match[1].toUpperCase();
      const path = match[2] || '';
      const fullPath = prefix ? `/${prefix}${path ? '/' + path : ''}` : `/${path}`;
      endpoints.push(`${method} ${fullPath.replace(/\/+/g, '/')}`);
    }
  }

  return { controllers, endpoints };
}

/**
 * Kombinierter Route-Scan fuer die Zieldatei selbst + projektweite Next.js-Routen.
 * Beruecksichtigt:
 *  - NestJS Controller in der Zieldatei
 *  - Zieldatei ist selbst eine Next.js route.ts -> Endpoints ableiten
 *  - Projektweite Auflistung aller Next.js route.ts Dateien
 */
async function scanRoutes(root, targetRel, stack) {
  /** @type {string[]} */
  const controllers = [];
  /** @type {string[]} */
  const endpoints = [];
  /** @type {string[]} */
  const nextRoutes = [];

  if (stack.hasNestJs) {
    // Pruefen ob die Zieldatei selbst ein Controller ist
    let targetContent;
    try {
      targetContent = await readFile(targetFile, 'utf8');
      const { controllers: c, endpoints: e } = extractNestJsRoutes(targetContent, targetFile);
      controllers.push(...c);
      endpoints.push(...e);
    } catch {
      // Datei nicht lesbar, weiter
    }
  }

  if (stack.hasNextJs) {
    // Projektweite Auflistung aller Next.js App-Router-Routen
    const allNextRoutes = await collectAllNextRoutes(root);
    nextRoutes.push(...allNextRoutes);

    // Self-route detection: Zieldatei ist selbst eine route.ts(x)
    if (isNextJsAppRoute(targetFile)) {
      await extractNextJsEndpoints(targetFile, root, endpoints);
    } else if (isNextJsPagesApiRoute(targetFile)) {
      // Pages Router: default export, HTTP-Methode schwer erkennbar -> ANY
      const url = derivePagesApiUrl(targetFile, root);
      endpoints.push(`ANY ${url}`);
    }

    // Importierende Routen werden ueber den allgemeinen importer-Scan erfasst
  }

  return { controllers, endpoints, nextRoutes };
}

/**
 * Liest eine Next.js route.ts, extrahiert HTTP-Methoden und leitet Endpoints ab.
 * Schreibt Ergebnisse direkt in das uebergebene Array.
 */
async function extractNextJsEndpoints(routeFile, root, endpointsOut) {
  let content;
  try {
    content = await readFile(routeFile, 'utf8');
  } catch {
    return;
  }
  const url = deriveAppRouterUrl(routeFile, root);
  const methods = extractNextJsMethods(content);
  if (methods.length === 0) {
    // Keine Methode erkannt -> generisch
    endpointsOut.push(`ANY ${url}`);
  } else {
    for (const method of methods) {
      endpointsOut.push(`${method} ${url}`);
    }
  }
}

// ---- Test Coverage -----------------------------------------------------

/** Findet Test/Spec-Dateien die das Ziel importieren */
async function findCoveringTests(root, targetRel) {
  const allFiles = await collectTsFiles(root);
  /** @type {string[]} */
  const covering = [];

  for (const file of allFiles) {
    if (!/(spec|test)\.(ts|tsx)$/.test(file)) continue;
    let content;
    try {
      content = await readFile(file, 'utf8');
    } catch {
      continue;
    }
    const specRelative = relative(root, file);
    if (fileImportsTarget(content, targetRel, specRelative)) {
      covering.push(specRelative);
    }
  }

  return covering;
}

// ---- ENV Var Scan ------------------------------------------------------

/** Extrahiert alle process.env.X Referenzen aus der Zieldatei */
async function extractEnvVars() {
  let content;
  try {
    content = await readFile(targetFile, 'utf8');
  } catch {
    return [];
  }
  const envPattern = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
  const vars = new Set();
  let match;
  while ((match = envPattern.exec(content)) !== null) {
    vars.add(match[1]);
  }
  return Array.from(vars).sort();
}

// ---- Migration Scan ----------------------------------------------------

/** Zaehlt Migrations-Dateien die in den letzten 7 Tagen veraendert wurden */
async function scanMigrations(root) {
  const migrationsDir = join(root, 'migrations');
  if (!existsSync(migrationsDir)) return { recent_count: 0, recent_files: [] };

  let entries;
  try {
    entries = await readdir(migrationsDir, { withFileTypes: true });
  } catch {
    return { recent_count: 0, recent_files: [] };
  }

  const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
  /** @type {string[]} */
  const recentFiles = [];

  for (const entry of entries) {
    if (!entry.isFile() || !/\.(ts|js)$/.test(entry.name)) continue;
    const full = join(migrationsDir, entry.name);
    let fileStat;
    try {
      fileStat = await stat(full);
    } catch {
      continue;
    }
    if (fileStat.mtimeMs > sevenDaysAgo) {
      recentFiles.push(entry.name);
    }
  }

  return { recent_count: recentFiles.length, recent_files: recentFiles };
}

// ---- Risk Score --------------------------------------------------------

/**
 * Berechnet den Risk Score basierend auf Heuristiken.
 * Low: <=2 Importer, keine Routes, Tests vorhanden, keine ENV, keine Migrations
 * Medium: 3-7 Importer ODER 1-2 Routes ODER Test-Gap ODER ENV vars
 * High: >=8 Importer ODER >=3 Routes ODER (Test-Gap + ENV + Migrations)
 */
function calculateRiskScore(data) {
  const { importerCount, endpointCount, testGap, envVarCount, recentMigrationCount } = data;
  /** @type {string[]} */
  const factors = [];

  if (importerCount > 0) factors.push(`${importerCount} importer${importerCount !== 1 ? 's' : ''}`);
  if (endpointCount > 0) factors.push(`${endpointCount} API route${endpointCount !== 1 ? 's' : ''}`);
  if (!testGap) factors.push('test coverage exists');
  if (testGap) factors.push('no test coverage (gap)');
  if (envVarCount > 0) factors.push(`${envVarCount} ENV var${envVarCount !== 1 ? 's' : ''}`);
  if (recentMigrationCount > 0) factors.push(`${recentMigrationCount} recent migration${recentMigrationCount !== 1 ? 's' : ''}`);

  // High: viele Importer, viele Routes, oder kritische Kombination
  if (importerCount >= 8 || endpointCount >= 3 || (testGap && envVarCount > 0 && recentMigrationCount > 0)) {
    return { score: 'high', factors };
  }

  // Low: wenig Importer, keine Routes, Tests vorhanden, keine ENV, keine Migrations
  if (importerCount <= 2 && endpointCount === 0 && !testGap && envVarCount === 0 && recentMigrationCount === 0) {
    return { score: 'low', factors };
  }

  // Medium: alles andere
  return { score: 'medium', factors };
}

// ---- Main --------------------------------------------------------------

async function main() {
  const stack = await detectStack(projectRoot);
  const importers = await findImporters(projectRoot, targetRelative);
  const routes = await scanRoutes(projectRoot, targetRelative, stack);
  const coveringTests = await findCoveringTests(projectRoot, targetRelative);
  const envVars = await extractEnvVars();
  const migrations = await scanMigrations(projectRoot);

  const allEndpoints = [...routes.endpoints];
  const testGap = coveringTests.length === 0;

  const { score, factors } = calculateRiskScore({
    importerCount: importers.length,
    endpointCount: allEndpoints.length,
    testGap,
    envVarCount: envVars.length,
    recentMigrationCount: migrations.recent_count,
  });

  /** @type {object} */
  const output = {
    target: targetRelative,
    stack: {
      framework: stack.framework,
      hasNextJs: stack.hasNextJs,
    },
    imports: {
      importers,
      count: importers.length,
    },
    routes: {
      controllers: routes.controllers,
      endpoints: allEndpoints,
      next_routes: routes.nextRoutes,
    },
    tests: {
      covering: coveringTests,
      count: coveringTests.length,
      gap: testGap,
    },
    env_vars: envVars,
    migrations: {
      recent_count: migrations.recent_count,
      recent_files: migrations.recent_files,
    },
    risk_score: score,
    risk_factors: factors,
  };

  if (jsonOnly) {
    process.stdout.write(JSON.stringify(output, null, 2) + '\n');
  } else {
    process.stdout.write(JSON.stringify(output, null, 2) + '\n');
  }
}

function printHelp() {
  const help = `
scan-impact.mjs — Safe Change Impact Analyzer

USAGE
  node scan-impact.mjs <target-file> [options]

ARGUMENTS
  <target-file>    Path to the TypeScript file you are about to change.
                   Relative to cwd or absolute.

OPTIONS
  --root <dir>     Project root for scanning (default: cwd)
  --json           Output raw JSON (same behavior, kept for scripting clarity)
  --help           Show this help text

EXAMPLES
  node scan-impact.mjs src/notifications/notifications.service.ts
  node scan-impact.mjs src/auth/auth.guard.ts --root /path/to/project
  node scan-impact.mjs src/users/users.service.ts --json

OUTPUT
  JSON with keys: target, stack, imports, routes, tests, env_vars, migrations,
  risk_score (low|medium|high), risk_factors

NOTES
  - Read-only: never modifies source files
  - Uses regex-based analysis (no AST). Dynamic imports are not detected.
  - See references/limitations.md for full trade-off list
`.trim();
  process.stdout.write(help + '\n');
}

main().catch((err) => {
  process.stderr.write(`scan-impact error: ${err.message}\n`);
  process.exit(1);
});


path: scripts/verify-gate.sh
content: #!/usr/bin/env bash
# verify-gate.sh — Safe Change Verify Gate
# Fuhert tsc → lint → test → build in Reihenfolge aus.
# Stoppt beim ersten Fehler. Farbige Ausgabe.
# Read-only: modifiziert keine Quelldateien.
#
# Usage:
#   bash verify-gate.sh [--root <project-root>] [--help]
#
# Exit codes:
#   0 — all checks passed
#   1 — a check failed (details printed above)

set -euo pipefail

# ---- Farben ------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color

# ---- Argumente ---------------------------------------------------------
PROJECT_ROOT="${PWD}"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --root)
      PROJECT_ROOT="$(realpath "$2")"
      shift 2
      ;;
    --help|-h)
      cat <<EOF
verify-gate.sh — Safe Change Verify Gate

USAGE
  bash verify-gate.sh [options]

OPTIONS
  --root <dir>    Project root (default: current working directory)
  --help          Show this help text

CHECKS (in order)
  1. tsc --noEmit          — TypeScript type-check
  2. npm run lint          — Linter (skipped if script not in package.json)
  3. npm test              — Test suite (skipped if script not in package.json)
  4. npm run build         — Build (skipped if script not in package.json)

EXIT CODES
  0 — all checks passed
  1 — a check failed
EOF
      exit 0
      ;;
    *)
      echo "Unknown argument: $1. Run with --help for usage." >&2
      exit 1
      ;;
  esac
done

# ---- Hilfsfunktionen ---------------------------------------------------

# Prueft ob ein npm-Script in package.json definiert ist
has_npm_script() {
  local script_name="$1"
  local pkg="${PROJECT_ROOT}/package.json"
  if [[ ! -f "$pkg" ]]; then return 1; fi
  # Einfacher grep — kein jq notwendig
  grep -q "\"${script_name}\"" "$pkg"
}

print_header() {
  echo ""
  echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  echo -e "${BOLD}${BLUE}  Safe Change — Verify Gate${NC}"
  echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  echo -e "  Root: ${PROJECT_ROOT}"
  echo ""
}

run_check() {
  local label="$1"
  local cmd="$2"
  echo -e "${YELLOW}▶ ${label}${NC}"
  echo -e "  ${cmd}"
  echo ""
  if (cd "${PROJECT_ROOT}" && eval "${cmd}"); then
    echo ""
    echo -e "${GREEN}✓ ${label} passed${NC}"
    echo ""
  else
    echo ""
    echo -e "${RED}✗ ${label} FAILED — stopping gate${NC}"
    echo ""
    exit 1
  fi
}

skip_check() {
  local label="$1"
  local reason="$2"
  echo -e "${YELLOW}○ ${label}${NC} — skipped (${reason})"
  echo ""
}

print_success() {
  echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  echo -e "${BOLD}${GREEN}  All checks passed. Safe to proceed.${NC}"
  echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  echo ""
}

# ---- Gate pruefen ------------------------------------------------------

print_header

# Pruefen ob package.json vorhanden
if [[ ! -f "${PROJECT_ROOT}/package.json" ]]; then
  echo -e "${RED}Error: package.json not found at ${PROJECT_ROOT}${NC}" >&2
  exit 1
fi

# Pruefen ob TypeScript installiert ist
TSC_CMD=""
if [[ -f "${PROJECT_ROOT}/node_modules/.bin/tsc" ]]; then
  TSC_CMD="node_modules/.bin/tsc --noEmit"
elif command -v tsc &>/dev/null; then
  TSC_CMD="tsc --noEmit"
fi

# ---- Check 1: TypeScript -----------------------------------------------
if [[ -n "$TSC_CMD" ]] && [[ -f "${PROJECT_ROOT}/tsconfig.json" ]]; then
  run_check "TypeScript (tsc --noEmit)" "${TSC_CMD}"
else
  skip_check "TypeScript" "tsc or tsconfig.json not found"
fi

# ---- Check 2: Lint ------------------------------------------------------
if has_npm_script "lint"; then
  run_check "Lint (npm run lint)" "npm run lint"
else
  skip_check "Lint" "no 'lint' script in package.json"
fi

# ---- Check 3: Tests -----------------------------------------------------
if has_npm_script "test"; then
  run_check "Tests (npm test)" "npm test"
else
  skip_check "Tests" "no 'test' script in package.json"
fi

# ---- Check 4: Build ----------------------------------------------------
if has_npm_script "build"; then
  run_check "Build (npm run build)" "npm run build"
else
  skip_check "Build" "no 'build' script in package.json"
fi

print_success


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)