@manukyalo/scopelock 2.1.0 → 2.3.0

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/bin/scopelock.js CHANGED
@@ -13,9 +13,11 @@
13
13
  * scopelock status Print manifest summary
14
14
  */
15
15
 
16
+ const { execSync } = require('child_process');
16
17
  const manifest = require('../src/manifest');
17
18
  const context = require('../src/context');
18
19
  const git = require('../src/git');
20
+ const blast = require('../src/blast');
19
21
 
20
22
  const [,, command, ...args] = process.argv;
21
23
 
@@ -54,13 +56,47 @@ switch (command) {
54
56
  }
55
57
 
56
58
  case 'check':
57
- git.check();
59
+ git.check(args);
58
60
  break;
59
61
 
60
62
  case 'status':
61
63
  manifest.status();
62
64
  break;
63
65
 
66
+ case 'blast-radius': {
67
+ const target = args[0];
68
+ if (!target) {
69
+ console.error('Usage: scopelock blast-radius <file>');
70
+ process.exit(1);
71
+ }
72
+ blast.printBlastRadius(target);
73
+ break;
74
+ }
75
+
76
+ case 'superlock': {
77
+ const target = args[0];
78
+ const reason = args.slice(1).join(' ') || 'production path — superlock applied';
79
+ if (!target) {
80
+ console.error('Usage: scopelock superlock <file> <reason>');
81
+ process.exit(1);
82
+ }
83
+ manifest.superlock(target, reason);
84
+ break;
85
+ }
86
+
87
+ case 'sudo-unlock': {
88
+ const target = args[0];
89
+ const ticketArg = args.find(a => a.startsWith('--human-approved='));
90
+ const ticket = ticketArg ? ticketArg.replace('--human-approved=', '') : null;
91
+ const reason = args.filter(a => !a.startsWith('--')).slice(1).join(' ');
92
+ if (!target || !ticket || !reason) {
93
+ console.error('Usage: scopelock sudo-unlock <file> --human-approved=<ticket> <reason>');
94
+ process.exit(1);
95
+ }
96
+ manifest.sudoUnlock(target, ticket, reason);
97
+ break;
98
+ }
99
+
64
100
  case 'allow-secret': {
65
101
  const target = args[0];
66
102
  const reason = args.slice(1).join(' ');
@@ -72,18 +108,81 @@ switch (command) {
72
108
  break;
73
109
  }
74
110
 
111
+ case 'snapshot': {
112
+ try {
113
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
114
+ const manifestObj = manifest.getManifest();
115
+
116
+ // Exclude .scopelock.json from stash — the manifest must survive the snapshot.
117
+ // On Windows, stash@{0} needs to be quoted.
118
+ const out = execSync(
119
+ `git stash push --include-untracked -m "scopelock-snapshot-${ts}" -- . ":(exclude).scopelock.json"`,
120
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
121
+ );
122
+
123
+ if (out.includes('No local changes to save')) {
124
+ manifestObj.lastSnapshot = 'clean';
125
+ } else {
126
+ // Re-apply so the working tree is fully restored; stash stays as our savepoint.
127
+ execSync(`git stash apply "stash@{0}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
128
+ manifestObj.lastSnapshot = 'dirty';
129
+ }
130
+
131
+ manifest.saveManifest(manifestObj);
132
+ console.log(`✅ Snapshot created. Run 'scopelock revert' to obliterate agent changes and restore this state.`);
133
+ } catch (e) {
134
+ console.error('Failed to create snapshot.', e.stderr || e.message);
135
+ process.exit(1);
136
+ }
137
+ break;
138
+ }
139
+
140
+ case 'revert': {
141
+ try {
142
+ const manifestObj = manifest.getManifest();
143
+ if (!manifestObj.lastSnapshot) {
144
+ console.error('❌ No snapshot found. Run `scopelock snapshot` first.');
145
+ process.exit(1);
146
+ }
147
+ console.log(`Obliterating agent mess...`);
148
+ execSync(`git reset --hard HEAD`, { stdio: ['pipe', 'pipe', 'pipe'] });
149
+ execSync(`git clean -fd`, { stdio: ['pipe', 'pipe', 'pipe'] });
150
+
151
+ if (manifestObj.lastSnapshot === 'dirty') {
152
+ const stashes = execSync('git stash list', { encoding: 'utf8' });
153
+ const match = stashes.match(/stash@\{(\d+)\}: .*?scopelock-snapshot/);
154
+ if (match) {
155
+ execSync(`git stash pop "stash@{${match[1]}}"`, { stdio: ['pipe', 'pipe', 'pipe'] });
156
+ }
157
+ }
158
+
159
+ manifestObj.lastSnapshot = null;
160
+ manifest.saveManifest(manifestObj);
161
+ console.log(`✅ Rollback complete. The repository has been restored.`);
162
+ } catch (e) {
163
+ console.error('Failed to revert.', e.stderr || e.message);
164
+ process.exit(1);
165
+ }
166
+ break;
167
+ }
168
+
75
169
  default:
76
170
  console.log(`
77
171
  scopelock — Anti-hallucination scope locking for AI coding agents.
78
172
 
79
173
  Usage:
80
- scopelock init Scan repo and generate .scopelock.json
81
- scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
82
- scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
83
- scopelock allow-secret <file> <reason> Bypass Secret Sentinel for a specific file
84
- scopelock context [task] Generate AI context block for a task
85
- scopelock check Check git diff for scope violations and secret leaks
86
- scopelock status Show manifest summary
174
+ scopelock init Scan repo and generate .scopelock.json
175
+ scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
176
+ scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
177
+ scopelock superlock <file> <reason> Permanent production-path lock (no override)
178
+ scopelock sudo-unlock <file> --human-approved=<ticket> <reason> Release a superlock
179
+ scopelock blast-radius <file> Show all files that import this file
180
+ scopelock allow-secret <file> <reason> Bypass Secret Sentinel for a specific file
181
+ scopelock snapshot Auto-snapshot repo state before an agent session
182
+ scopelock revert Rollback to the last snapshot
183
+ scopelock context [task] Generate AI context block for a task
184
+ scopelock check [--require-tests] Check git diff for violations and secret leaks
185
+ scopelock status Show manifest summary
87
186
 
88
187
 
89
188
  Examples:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manukyalo/scopelock",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Anti-hallucination scope locking for AI coding agents.",
5
5
  "main": "bin/scopelock.js",
6
6
  "bin": {
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: blast-radius-map
3
+ description: "Godmode Skill: Before modifying any file, run 'scopelock blast-radius <file>' to see every other file that imports it. Prevents scope creep by making the full impact of a change visible BEFORE the agent writes a single line."
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ An AI agent can't know what it doesn't know. If it modifies `src/utils/auth.ts` without understanding that 14 other files import it, it has no idea if the function signature change it's making will cascade into 14 broken components.
9
+
10
+ This skill instructs the agent to **check before it touches**, not after.
11
+
12
+ ## Agent Protocol
13
+
14
+ ### MANDATORY: Before editing any file
15
+
16
+ 1. Run the blast radius check first:
17
+ ```bash
18
+ scopelock blast-radius src/utils/auth.ts
19
+ ```
20
+
21
+ 2. Read the output:
22
+ ```
23
+ 💥 Blast Radius: src/utils/auth.ts
24
+
25
+ 7 file(s) directly import this file:
26
+
27
+ → src/pages/login.tsx
28
+ → src/pages/register.tsx
29
+ → src/api/session.ts
30
+ → src/middleware.ts
31
+ → src/hooks/useUser.ts
32
+ → test/auth.test.ts
33
+ → test/session.test.ts
34
+
35
+ ⚠️ Modifying 'src/utils/auth.ts' may impact all 7 of the above file(s).
36
+ ```
37
+
38
+ 3. **Assess the risk** based on what you see:
39
+ - **0 dependents** → safe to modify freely.
40
+ - **1–5 dependents** → proceed carefully, check each dependent after your change.
41
+ - **6+ dependents** → this is a high-blast-radius file. Consider locking it and building a new abstraction instead of modifying it directly.
42
+
43
+ 4. If you decide to proceed, lock all other files first:
44
+ ```bash
45
+ scopelock lock src/pages/login.tsx "blast radius protection"
46
+ scopelock lock src/middleware.ts "blast radius protection"
47
+ # ... etc
48
+ ```
49
+
50
+ 5. Make your change to the target file only.
51
+
52
+ 6. Run `scopelock check` to verify no locked files were touched.
53
+
54
+ ## Why this exists
55
+
56
+ The most expensive bugs come from changes that seemed small but weren't. This skill makes the full impact of every change explicit before the agent commits to it.
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: dependency-lockdown
3
+ description: "Godmode Skill: Prevents AI agents from silently adding or upgrading npm/pip/cargo packages. Dependency manifest files (package.json, requirements.txt, etc.) are automatically locked by scopelock on init. Any diff touching these files will fail scopelock check until explicitly unlocked with a reason."
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Dependency drift is one of the most common and expensive agent mistakes. An agent trying to fix one bug will silently add 12 packages, bloat the bundle, and introduce unknown vulnerabilities. This skill makes that impossible.
9
+
10
+ ## How it Works
11
+
12
+ On `scopelock init`, the following files are **automatically locked** without any manual action required:
13
+ - `package.json`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
14
+ - `requirements.txt`, `Pipfile`, `Pipfile.lock`, `poetry.lock`
15
+ - `Cargo.toml`, `Cargo.lock`, `go.mod`, `go.sum`
16
+
17
+ ## Agent Protocol
18
+
19
+ ### When you need to add a dependency:
20
+ 1. You MUST explicitly unlock the manifest first with a clear reason:
21
+ ```bash
22
+ scopelock unlock package.json "adding zod for runtime validation of API responses"
23
+ ```
24
+ 2. Make your change (add the dependency to the manifest).
25
+ 3. Run `scopelock check` to confirm the change is authorized.
26
+ 4. Re-lock the manifest immediately after:
27
+ ```bash
28
+ scopelock lock package.json "dependencies updated and reviewed"
29
+ ```
30
+
31
+ ### When you do NOT need a new dependency:
32
+ - If you find yourself reaching for a new package, stop and ask: can this be solved with a built-in or existing dependency?
33
+ - The lock exists to force that question before the decision is made.
34
+
35
+ ## Why this exists
36
+
37
+ Agents never "just need one package." Every dependency is a long-term maintenance cost, a security surface, and a potential supply chain attack vector. This skill enforces that every dependency addition is a deliberate, logged, human-approved decision.
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: production-path-lock
3
+ description: "Godmode Skill: A permanent, override-resistant lock for critical production paths like /auth, /billing, /migrations. Regular 'scopelock unlock' cannot bypass it. Requires a second human's explicit sign-off via 'scopelock sudo-unlock' with a ticket."
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Some files must never be touched by an AI agent without explicit human sign-off. Your `/auth` logic, billing handlers, and database migration files are not negotiable. A `superlock` makes this a physical constraint, not a policy.
9
+
10
+ A superlocked file cannot be overridden by `scopelock unlock`. The agent must stop, escalate to a human, and provide a traceable ticket before any modification is allowed.
11
+
12
+ ## Recommended Files to Superlock on Every Project
13
+
14
+ ```bash
15
+ # Authentication
16
+ scopelock superlock src/auth/token.ts "core auth — requires senior review"
17
+ scopelock superlock src/middleware/auth.ts "core auth — requires senior review"
18
+
19
+ # Billing & Payments
20
+ scopelock superlock src/billing/stripe.ts "billing — requires finance team approval"
21
+ scopelock superlock src/billing/webhooks.ts "billing — requires finance team approval"
22
+
23
+ # Database Migrations
24
+ scopelock superlock migrations/ "schema changes — requires DBA review"
25
+
26
+ # Security-critical config
27
+ scopelock superlock .env.production "production secrets — never touch"
28
+ ```
29
+
30
+ ## Agent Protocol
31
+
32
+ ### When you encounter a superlocked file:
33
+ ```
34
+ ❌ 'src/auth/token.ts' is SUPERLOCKED and cannot be unlocked with 'scopelock unlock'.
35
+ This path is a protected production route.
36
+ Use: scopelock sudo-unlock src/auth/token.ts --human-approved=<ticket> <reason>
37
+ ```
38
+
39
+ 1. **STOP.** Do not attempt to work around the lock.
40
+ 2. **Escalate.** Tell the human you cannot proceed without their explicit approval.
41
+ 3. **The human must run:**
42
+ ```bash
43
+ scopelock sudo-unlock src/auth/token.ts --human-approved=JIRA-123 "fixing JWT expiry bug approved in PR-456"
44
+ ```
45
+ 4. Only after you see the `SUDO-UNLOCKED` confirmation may you proceed.
46
+ 5. Re-superlock the file immediately after your change is committed:
47
+ ```bash
48
+ scopelock superlock src/auth/token.ts "re-locked after JWT fix — JIRA-123"
49
+ ```
50
+
51
+ ## The Audit Trail
52
+
53
+ Every `sudo-unlock` is permanently logged in `.scopelock.json` with:
54
+ - The timestamp
55
+ - The human-approved ticket number
56
+ - The full reason string
57
+
58
+ This creates a traceable, auditable record of every time a production path was modified — essential for SOC2, GDPR, and any compliance review.
59
+
60
+ ## Why this exists
61
+
62
+ `locked` is a request. `superlocked` is a wall. Some files need a wall.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: rollback-snapshot
3
+ description: "Godmode Skill: Creates a one-command safety net before every agent session. If the agent goes rogue, a single command obliterates all changes and perfectly restores the repository to its pre-session state."
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Before you let an agent loose on a codebase, you need a guaranteed escape hatch. `scopelock snapshot` creates that escape hatch using git's native stash mechanism. It is instant, requires no external tools, and can restore the repo to its exact pre-session state in under one second.
9
+
10
+ ## How Storage Works
11
+
12
+ - The snapshot is stored in **git's local stash** (`.git/refs/stash`) — it never leaves your machine and is never pushed to GitHub.
13
+ - The `.scopelock.json` manifest stores a pointer (`lastSnapshot: "dirty" | "clean"`) so `scopelock revert` knows what to restore.
14
+ - If you clone the repo on a new machine, the snapshot is gone — this is correct. Snapshots are session-scoped, not repository-scoped.
15
+
16
+ ## Agent Protocol
17
+
18
+ ### At the start of EVERY agent session:
19
+ ```bash
20
+ scopelock snapshot
21
+ ```
22
+ This is the first command you run, before writing a single line of code.
23
+
24
+ ### If the session goes well:
25
+ Simply commit your work normally. The stash will remain in git until git's garbage collection cleans it up automatically (default: 90 days).
26
+
27
+ ### If the agent goes rogue:
28
+ ```bash
29
+ scopelock revert
30
+ ```
31
+ This command:
32
+ 1. Runs `git reset --hard HEAD` — obliterates all tracked file changes.
33
+ 2. Runs `git clean -fd` — deletes all untracked files the agent created.
34
+ 3. Pops the stash — restores your working tree to the exact state at snapshot time.
35
+
36
+ ## When to Use
37
+
38
+ - Before any multi-file refactor.
39
+ - Before any session where the agent is given broad instructions like "fix all the TypeScript errors."
40
+ - Before any experiment where you are not 100% sure of the outcome.
41
+
42
+ ## Why this exists
43
+
44
+ `Ctrl+Z` doesn't work across a 45-minute agent session. This skill gives you a time machine.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: secret-sentinel
3
+ description: "Godmode Skill: Physically blocks AI agents from committing API keys, tokens, or .env leaks. scopelock check scans every added line in the git diff for high-entropy secrets before the commit is allowed. This is a hard block — not a warning."
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Leaked secrets are the number one cause of production security incidents caused by AI agents. An agent will hardcode an API key to "test something quickly" and forget to remove it. This skill prevents that from ever reaching the repo.
9
+
10
+ ## What Gets Blocked
11
+
12
+ The Secret Sentinel scans every newly added line for:
13
+ - **AWS Access Keys** (`AKIA...`)
14
+ - **Stripe Secret Keys** (`sk_live_...`, `sk_test_...`)
15
+ - **GitHub Tokens** (`ghp_...`, `ghs_...`)
16
+ - **Slack Tokens** (`xoxb-...`, `xoxs-...`)
17
+ - **Generic hardcoded secrets** (any `api_key = "..."`, `password = "..."`, `token = "..."` with a value 16+ chars long)
18
+
19
+ ## Agent Protocol
20
+
21
+ ### Before every commit:
22
+ ```bash
23
+ scopelock check
24
+ ```
25
+ If a secret is detected, you will see:
26
+ ```
27
+ ❌ VIOLATION: SECRET LEAK [Stripe Secret Key] detected in 'src/api.ts' on line 42.
28
+ ```
29
+
30
+ ### How to resolve:
31
+ 1. **Remove the secret from the file** — use an environment variable instead:
32
+ ```ts
33
+ // WRONG — will be blocked
34
+ const key = "sk_live_1234abcdefgh";
35
+
36
+ // CORRECT — reads from environment
37
+ const key = process.env.STRIPE_SECRET_KEY;
38
+ ```
39
+ 2. Add the secret to `.env` (which should be in `.gitignore`).
40
+ 3. Add the variable name to `.env.example` so other developers know it exists.
41
+
42
+ ### Intentional exception (mock/test keys only):
43
+ If you are intentionally committing a **mock** key for testing purposes, a human must explicitly authorize it:
44
+ ```bash
45
+ scopelock allow-secret test/fixtures/mock.ts "contains a mock stripe key for unit tests — not a real key"
46
+ ```
47
+ This bypass is logged permanently in `.scopelock.json` for audit purposes.
48
+
49
+ ## Why this exists
50
+
51
+ You cannot undo a leaked secret that was pushed to a public GitHub repo. This skill makes leaking a secret a physical impossibility, not a code review catch.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: test-coverage-gate
3
+ description: "Godmode Skill: Forces the AI agent to write or update test files whenever it modifies application logic. Run 'scopelock check --require-tests' before committing to enforce this rule."
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ This skill teaches the agent how to navigate the `scopelock` Test Coverage Gate.
9
+ When `--require-tests` is active, the CLI will hard-block any commit that modifies a source file (`.js`, `.ts`, `.py`, etc.) if a corresponding test file (`.test.ts`, `.spec.js`, `test/`) is not also modified in the same diff.
10
+
11
+ ## Agent Protocol
12
+
13
+ 1. **Before writing code**: If you are about to modify application logic, understand that your changes will be rejected unless you also provide test coverage.
14
+ 2. **Write the code**: Implement the requested feature or fix.
15
+ 3. **Write the test**: You *must* update the corresponding test file or create a new one. The file path must contain `.test.`, `.spec.`, or be inside a `test/` or `__tests__/` directory.
16
+ 4. **Validation**: Run `scopelock check --require-tests` to mathematically verify your diff will pass the coverage gate.
17
+ 5. **Ship**: Only after the test gate is passed are you allowed to commit or mark the task as complete.
18
+
19
+ ## Why this exists
20
+
21
+ Agents often write code and immediately declare a task "done" without proving it works. This physical gate prevents the agent from bypassing the verification phase. No tests, no merge.
package/src/blast.js ADDED
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * src/blast.js
5
+ *
6
+ * Cross-File Blast Radius Map.
7
+ *
8
+ * Given a file path, scan the entire repo for any file that imports or
9
+ * requires it. Returns an array of dependent file paths.
10
+ *
11
+ * Handles the following import patterns (JS/TS/Python/Go):
12
+ * import ... from './path/to/file'
13
+ * require('./path/to/file')
14
+ * from './path/to/file' import ... (Python)
15
+ * import "./path/to/file"
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // Directories that are never part of the blast radius analysis.
22
+ const IGNORED_DIRS = new Set([
23
+ 'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
24
+ '.turbo', '.cache', '__pycache__', '.venv', 'venv', 'target',
25
+ ]);
26
+
27
+ /**
28
+ * Recursively walk a directory and collect all files.
29
+ * @param {string} dir
30
+ * @returns {string[]}
31
+ */
32
+ function walkDir(dir) {
33
+ const results = [];
34
+ let entries;
35
+ try {
36
+ entries = fs.readdirSync(dir, { withFileTypes: true });
37
+ } catch {
38
+ return results;
39
+ }
40
+ for (const entry of entries) {
41
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
42
+ if (IGNORED_DIRS.has(entry.name)) continue;
43
+ const full = path.join(dir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ results.push(...walkDir(full));
46
+ } else {
47
+ results.push(full);
48
+ }
49
+ }
50
+ return results;
51
+ }
52
+
53
+ /**
54
+ * Generate all possible import path variants for a given target file.
55
+ * For example, given src/auth/token.ts, this yields:
56
+ * ./token, ../auth/token, ../../src/auth/token, etc.
57
+ * We match against these from each candidate file's directory.
58
+ *
59
+ * @param {string} targetRelative e.g. "src/auth/token.ts"
60
+ * @returns {string[]} stem variants (without extension, for partial matching)
61
+ */
62
+ function getTargetStems(targetRelative) {
63
+ const normalized = targetRelative.replace(/\\/g, '/');
64
+ const noExt = normalized.replace(/\.[^.]+$/, '');
65
+ return [normalized, noExt];
66
+ }
67
+
68
+ /**
69
+ * Check if a file's content contains an import/require of the target.
70
+ *
71
+ * @param {string} fileContent
72
+ * @param {string[]} targetStems
73
+ * @param {string} fileDir Absolute directory of the importing file
74
+ * @param {string} repoRoot Absolute repo root
75
+ * @param {string} targetAbsolute Absolute path of the target file
76
+ * @returns {boolean}
77
+ */
78
+ function fileImportsTarget(fileContent, targetAbsolute, fileDir) {
79
+ // Extract all quoted string literals that look like import paths
80
+ const importPathRe = /(?:from|import|require)\s*\(?['"]([^'"]+)['"]\)?/g;
81
+ let match;
82
+ while ((match = importPathRe.exec(fileContent)) !== null) {
83
+ const importPath = match[1];
84
+ // Only resolve relative paths; skip node_module specifiers
85
+ if (!importPath.startsWith('.')) continue;
86
+ try {
87
+ const resolved = path.resolve(fileDir, importPath);
88
+ // Match with or without extension
89
+ const targetNoExt = targetAbsolute.replace(/\.[^.]+$/, '');
90
+ if (
91
+ resolved === targetAbsolute ||
92
+ resolved === targetNoExt ||
93
+ resolved + path.extname(targetAbsolute) === targetAbsolute
94
+ ) {
95
+ return true;
96
+ }
97
+ } catch {
98
+ // If resolution fails, skip
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Compute the full blast radius for a given file.
106
+ *
107
+ * @param {string} targetFile Relative or absolute path to the file.
108
+ * @returns {{ target: string, dependents: string[], total: number }}
109
+ */
110
+ function blastRadius(targetFile) {
111
+ const repoRoot = process.cwd();
112
+ const targetAbs = path.resolve(repoRoot, targetFile);
113
+ const targetRel = path.relative(repoRoot, targetAbs).replace(/\\/g, '/');
114
+ const allFiles = walkDir(repoRoot);
115
+ const dependents = [];
116
+
117
+ for (const f of allFiles) {
118
+ // Skip the target itself
119
+ if (path.resolve(f) === targetAbs) continue;
120
+
121
+ let content;
122
+ try {
123
+ content = fs.readFileSync(f, 'utf8');
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ if (fileImportsTarget(content, targetAbs, path.dirname(f))) {
129
+ dependents.push(path.relative(repoRoot, f).replace(/\\/g, '/'));
130
+ }
131
+ }
132
+
133
+ return { target: targetRel, dependents, total: dependents.length };
134
+ }
135
+
136
+ /**
137
+ * Print a human-readable blast radius report to stdout.
138
+ *
139
+ * @param {string} targetFile
140
+ */
141
+ function printBlastRadius(targetFile) {
142
+ const { target, dependents, total } = blastRadius(targetFile);
143
+
144
+ console.log(`\n💥 Blast Radius: ${target}\n`);
145
+
146
+ if (total === 0) {
147
+ console.log(' No other files import this file. Safe to modify.\n');
148
+ return;
149
+ }
150
+
151
+ console.log(` ${total} file(s) directly import this file:\n`);
152
+ for (const dep of dependents) {
153
+ console.log(` → ${dep}`);
154
+ }
155
+ console.log('');
156
+ console.log(` ⚠️ Modifying '${target}' may impact all ${total} of the above file(s).`);
157
+ console.log(` Run 'scopelock lock ${target}' to protect it before your session.\n`);
158
+ }
159
+
160
+ module.exports = { blastRadius, printBlastRadius };
package/src/git.js CHANGED
@@ -21,7 +21,8 @@ const { getChangedLines } = require('./diff');
21
21
  const { extractFunctions } = require('./parser');
22
22
  const { detectSecret } = require('./secrets');
23
23
 
24
- function check() {
24
+ function check(args = []) {
25
+ const requireTests = args.includes('--require-tests');
25
26
  const manifest = getManifest();
26
27
  let diffOutput;
27
28
 
@@ -47,6 +48,24 @@ function check() {
47
48
  }
48
49
 
49
50
  const violations = [];
51
+
52
+ // ── Tier -1: Test Coverage Gate ───────────────────────────────────────────
53
+ if (requireTests) {
54
+ const hasSourceChanges = changedFiles.some(f =>
55
+ !f.includes('.test.') && !f.includes('.spec.') && !f.includes('/test/') && !f.includes('/__tests__/') &&
56
+ (f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.py') || f.endsWith('.go') || f.endsWith('.rs'))
57
+ );
58
+ const hasTestChanges = changedFiles.some(f =>
59
+ f.includes('.test.') || f.includes('.spec.') || f.includes('/test/') || f.includes('/__tests__/')
60
+ );
61
+ if (hasSourceChanges && !hasTestChanges) {
62
+ violations.push({
63
+ type: 'test-gate',
64
+ file: 'N/A',
65
+ message: 'TEST GATE VIOLATION: Source logic was modified, but no tests were added or updated. You must write tests to pass `--require-tests`.'
66
+ });
67
+ }
68
+ }
50
69
 
51
70
  for (const file of changedFiles) {
52
71
  const normalizedFile = file.replace(/\\/g, '/');
@@ -74,11 +93,12 @@ function check() {
74
93
  }
75
94
 
76
95
  // ── Tier 1: File-level lock ─────────────────────────────────────────────
77
- if (entry && entry.status === 'locked') {
96
+ if (entry && (entry.status === 'locked' || entry.status === 'superlocked')) {
97
+ const label = entry.status === 'superlocked' ? 'SUPERLOCKED' : 'LOCKED';
78
98
  violations.push({
79
99
  type: 'file',
80
100
  file: normalizedFile,
81
- message: `File '${normalizedFile}' is LOCKED.`,
101
+ message: `File '${normalizedFile}' is ${label}.`,
82
102
  });
83
103
  continue; // No need to check functions if the whole file is locked
84
104
  }
package/src/manifest.js CHANGED
@@ -177,6 +177,63 @@ function lock(target, reason = 'manually locked') {
177
177
  }
178
178
  }
179
179
 
180
+ /**
181
+ * Superlock a file — permanent, override-resistant production path lock.
182
+ * Cannot be removed by 'unlock'. Requires 'sudo-unlock' with a human-approved ticket.
183
+ *
184
+ * @param {string} file File path
185
+ * @param {string} reason Mandatory reason string
186
+ */
187
+ function superlock(file, reason) {
188
+ const relativePath = file.replace(/\\/g, '/');
189
+ const manifest = getManifest();
190
+ ensureFileEntry(manifest, relativePath);
191
+ const entry = manifest.files[relativePath];
192
+
193
+ entry.status = 'superlocked';
194
+ entry.history.push({
195
+ timestamp: new Date().toISOString(),
196
+ action: 'superlocked',
197
+ reason,
198
+ });
199
+ saveManifest(manifest);
200
+ console.log(`🔐 SUPERLOCKED ${relativePath}. Only 'scopelock sudo-unlock' with a human-approved ticket can release this.`);
201
+ }
202
+
203
+ /**
204
+ * Remove a superlock from a file. Requires an explicit human-approved ticket string.
205
+ * This is the only command that can override a 'superlocked' file.
206
+ *
207
+ * @param {string} file File path
208
+ * @param {string} ticket Human-approved ticket (e.g. "JIRA-123" or "PR-456")
209
+ * @param {string} reason Mandatory reason string
210
+ */
211
+ function sudoUnlock(file, ticket, reason) {
212
+ if (!ticket || !reason) {
213
+ console.error('Usage: scopelock sudo-unlock <file> --human-approved=<ticket> <reason>');
214
+ process.exit(1);
215
+ }
216
+ const relativePath = file.replace(/\\/g, '/');
217
+ const manifest = getManifest();
218
+ ensureFileEntry(manifest, relativePath);
219
+ const entry = manifest.files[relativePath];
220
+
221
+ if (entry.status !== 'superlocked') {
222
+ console.error(`'${relativePath}' is not superlocked. Use 'scopelock unlock' instead.`);
223
+ process.exit(1);
224
+ }
225
+
226
+ entry.status = 'active';
227
+ entry.history.push({
228
+ timestamp: new Date().toISOString(),
229
+ action: 'sudo-unlocked',
230
+ humanApproved: ticket,
231
+ reason,
232
+ });
233
+ saveManifest(manifest);
234
+ console.log(`🔓 SUDO-UNLOCKED ${relativePath}. Ticket: ${ticket}. Reason: ${reason}`);
235
+ }
236
+
180
237
  /**
181
238
  * Unlock a file or a specific function.
182
239
  *
@@ -206,6 +263,15 @@ function unlock(target, reason) {
206
263
  saveManifest(manifest);
207
264
  console.log(`🔓 Unlocked function '${funcName}' in ${relativePath}. Reason: ${reason}`);
208
265
  } else {
266
+ // Guard against bypassing a superlock with a normal unlock
267
+ if (entry.status === 'superlocked') {
268
+ console.error(
269
+ `❌ '${relativePath}' is SUPERLOCKED and cannot be unlocked with 'scopelock unlock'.\n` +
270
+ ` This path is a protected production route.\n` +
271
+ ` Use: scopelock sudo-unlock ${relativePath} --human-approved=<ticket> <reason>`
272
+ );
273
+ process.exit(1);
274
+ }
209
275
  entry.status = 'active';
210
276
  entry.history.push(historyEntry);
211
277
  saveManifest(manifest);
@@ -220,9 +286,10 @@ function status() {
220
286
  const manifest = getManifest();
221
287
  const files = Object.entries(manifest.files);
222
288
 
223
- const locked = files.filter(([, v]) => v.status === 'locked');
224
- const active = files.filter(([, v]) => v.status === 'active');
225
- const unscoped = files.filter(([, v]) => v.status === 'unscoped');
289
+ const superlocked = files.filter(([, v]) => v.status === 'superlocked');
290
+ const locked = files.filter(([, v]) => v.status === 'locked');
291
+ const active = files.filter(([, v]) => v.status === 'active');
292
+ const unscoped = files.filter(([, v]) => v.status === 'unscoped');
226
293
 
227
294
  // Count locked functions across all files
228
295
  let lockedFnCount = 0;
@@ -294,4 +361,4 @@ function allowSecret(file, reason) {
294
361
  console.log(`⚠️ Secret Sentinel bypassed for ${relativePath}. Reason: ${reason}`);
295
362
  }
296
363
 
297
- module.exports = { init, lock, unlock, allowSecret, status, getManifest, saveManifest };
364
+ module.exports = { init, lock, unlock, superlock, sudoUnlock, allowSecret, status, getManifest, saveManifest };
package/test/run.js CHANGED
@@ -156,6 +156,59 @@ console.log('\n--- Test 14: scopelock context output ---');
156
156
  const ctx = run(`${CLI} context "update the WIP function"`);
157
157
  assert(ctx.includes('SCOPE CONTEXT'), 'context output contains header');
158
158
 
159
+ console.log('\n--- Test 15: Test Coverage Gate (Missing Tests) ---');
160
+ fs.writeFileSync('feature.js', 'console.log("new logic");\n');
161
+ run('git add feature.js');
162
+ const testGateOut = run(`${CLI} check --require-tests`, true);
163
+ assert(testGateOut.includes('TEST GATE VIOLATION'), 'check catches missing tests when flag is used');
164
+
165
+ console.log('\n--- Test 16: Test Coverage Gate (Tests Provided) ---');
166
+ fs.writeFileSync('feature.test.js', 'console.log("test for logic");\n');
167
+ run('git add feature.test.js');
168
+ const testGatePass = run(`${CLI} check --require-tests`);
169
+ assert(testGatePass.includes('passed'), 'check passes when test files accompany source files');
170
+
171
+ console.log('\n--- Test 17: Rollback Snapshot Creation ---');
172
+ run(`${CLI} snapshot`);
173
+ const m4 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
174
+ assert(m4.lastSnapshot === 'clean' || m4.lastSnapshot === 'dirty', 'snapshot state is tracked in manifest');
175
+
176
+ console.log('\n--- Test 18: Rollback Revert ---');
177
+ fs.writeFileSync('rogue.js', 'I am a rogue agent destroying things');
178
+ run(`${CLI} revert`);
179
+ assert(!fs.existsSync('rogue.js'), 'revert destroys untracked rogue files');
180
+ const m5 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
181
+ assert(m5.lastSnapshot === null, 'revert clears the snapshot marker');
182
+
183
+ console.log('\n--- Test 19: Production Path Lock (superlock) ---');
184
+ run(`${CLI} superlock app.js "core auth logic — requires PR approval to modify"`);
185
+ const m6 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
186
+ assert(m6.files['app.js'].status === 'superlocked', 'superlock sets status to superlocked');
187
+
188
+ console.log('\n--- Test 20: superlock blocks regular unlock ---');
189
+ const superUnlockOut = run(`${CLI} unlock app.js "trying to bypass"`, true);
190
+ assert(superUnlockOut.includes('SUPERLOCKED'), 'regular unlock is blocked on superlocked file');
191
+
192
+ console.log('\n--- Test 21: superlock blocks scopelock check ---');
193
+ fs.appendFileSync('app.js', '\n// rogue addition\n');
194
+ const superCheckOut = run(`${CLI} check`, true);
195
+ assert(superCheckOut.includes('SUPERLOCKED'), 'check reports SUPERLOCKED violation');
196
+ run('git restore app.js');
197
+
198
+ console.log('\n--- Test 22: sudo-unlock releases a superlock with ticket ---');
199
+ run(`${CLI} sudo-unlock app.js --human-approved=JIRA-999 "approved by senior eng for critical hotfix"`);
200
+ const m7 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
201
+ assert(m7.files['app.js'].status === 'active', 'sudo-unlock transitions superlocked to active');
202
+ const sudoHistory = m7.files['app.js'].history.find(h => h.action === 'sudo-unlocked');
203
+ assert(sudoHistory && sudoHistory.humanApproved === 'JIRA-999', 'sudo-unlock logs the human-approved ticket');
204
+
205
+ console.log('\n--- Test 23: Blast Radius Map ---');
206
+ // Make app.js import readme.txt by creating an importer
207
+ fs.writeFileSync('importer.js', `import { something } from './app';\n`);
208
+ const blastOut = run(`${CLI} blast-radius app.js`);
209
+ assert(blastOut.includes('Blast Radius'), 'blast-radius outputs the report header');
210
+ assert(blastOut.includes('importer.js'), 'blast-radius correctly identifies importer.js as a dependent');
211
+
159
212
  // ─── Done ─────────────────────────────────────────────────────────────────────
160
213
 
161
- console.log('\n✅ All 14 tests passed.\n');
214
+ console.log('\n✅ All 23 tests passed.\n');