@manukyalo/scopelock 2.0.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/README.md +141 -0
- package/bin/scopelock.js +84 -0
- package/package.json +15 -0
- package/skills/scope-enforcement/SKILL.md +152 -0
- package/src/context.js +76 -0
- package/src/diff.js +68 -0
- package/src/git.js +129 -0
- package/src/manifest.js +259 -0
- package/src/parser.js +141 -0
- package/test/run.js +146 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# scopelock
|
|
2
|
+
|
|
3
|
+
**Anti-hallucination scope locking for AI coding agents.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @manukyalo/scopelock
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`scopelock` solves a specific, well-documented problem: AI coding agents frequently exhibit scope creep ā modifying files outside the intended change ā and lack persistent project memory across sessions.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Companion Skill
|
|
14
|
+
|
|
15
|
+
`skills/scope-enforcement/SKILL.md` is a structured 3-checkpoint workflow for AI agents that enforces scopelock boundaries across every phase of a session. Load it into: **Antigravity**, **Claude Code**, **Gemini CLI**, **Cursor**, **Kiro**.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The Problem
|
|
20
|
+
|
|
21
|
+
You ask an AI agent to fix the login button. It also refactors your auth middleware, updates 3 unrelated components, and breaks a working API route. You had no pre-commit guardrail to stop it.
|
|
22
|
+
|
|
23
|
+
`scopelock` enforces scope at two levels:
|
|
24
|
+
|
|
25
|
+
| Protection | What it stops |
|
|
26
|
+
|------------|--------------|
|
|
27
|
+
| **File-level** | Agent modifies any file marked `locked` |
|
|
28
|
+
| **Function-level** | Agent modifies lines inside a locked function body, even if the surrounding file is `active` |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
### `scopelock init`
|
|
35
|
+
Scan the repo and generate `.scopelock.json`. Automatically ignores `node_modules`, `.git`, `.next`, `dist`, `build`, `out`, `coverage`, and other build artifacts.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
scopelock init
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### `scopelock status`
|
|
42
|
+
Print a human-readable summary of the manifest.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
scopelock status
|
|
46
|
+
|
|
47
|
+
# š scopelock status
|
|
48
|
+
#
|
|
49
|
+
# š locked ā 3 file(s), 2 function(s)
|
|
50
|
+
# āļø active ā 1 file(s)
|
|
51
|
+
# ⬠unscoped ā 98 file(s)
|
|
52
|
+
#
|
|
53
|
+
# Locked files:
|
|
54
|
+
# src/lib/supabase.ts
|
|
55
|
+
# src/middleware.ts
|
|
56
|
+
# āāā middleware() [locked]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `scopelock lock <file>[:<function>] [reason]`
|
|
60
|
+
Lock a whole file or a specific named function.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Lock a whole file
|
|
64
|
+
scopelock lock src/lib/supabase.ts "production client ā stable"
|
|
65
|
+
|
|
66
|
+
# Lock a specific function (validates it exists before locking)
|
|
67
|
+
scopelock lock src/auth/token.ts:validateToken "tested ā do not touch"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `scopelock unlock <file>[:<function>] <reason>`
|
|
71
|
+
Unlock a file or function. Reason is mandatory and logged.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
scopelock unlock src/auth/token.ts:validateToken "fixing JWT expiry edge case"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `scopelock check`
|
|
78
|
+
Two-tier scope violation check against `git diff HEAD`. Exits non-zero on violations.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
scopelock check
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `scopelock context [task]`
|
|
85
|
+
Output a token-efficient AI context block with all locks clearly flagged.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
scopelock context "Fix the broken checkout flow" | clip
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Pre-commit Hook
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
echo '#!/bin/sh
|
|
97
|
+
scopelock check' > .git/hooks/pre-commit
|
|
98
|
+
chmod +x .git/hooks/pre-commit
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Once wired, no agent or developer can commit a scope violation.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## File Statuses
|
|
106
|
+
|
|
107
|
+
| Status | Meaning |
|
|
108
|
+
|--------|---------|
|
|
109
|
+
| `unscoped` | Not yet classified |
|
|
110
|
+
| `locked` | Stable ā do not modify without an explicit `scopelock unlock` |
|
|
111
|
+
| `active` | In scope for the current task |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Language Support for Function-level Locking
|
|
116
|
+
|
|
117
|
+
| Language | Extensions |
|
|
118
|
+
|----------|-----------|
|
|
119
|
+
| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` |
|
|
120
|
+
| TypeScript | `.ts`, `.tsx` |
|
|
121
|
+
| Python | `.py` |
|
|
122
|
+
|
|
123
|
+
All other file types fall back to file-level locking automatically.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Commit `.scopelock.json`
|
|
128
|
+
|
|
129
|
+
The manifest is project state, not a personal config. Commit it so your whole team ā and all their AI agents ā share the same scope boundaries.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Zero Dependencies
|
|
134
|
+
|
|
135
|
+
Built entirely on Node.js built-ins (`fs`, `path`, `child_process`). No runtime dependencies.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/bin/scopelock.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bin/scopelock.js ā CLI entry point
|
|
6
|
+
*
|
|
7
|
+
* Commands:
|
|
8
|
+
* scopelock init Scan repo, generate .scopelock.json
|
|
9
|
+
* scopelock lock <file>[:<func>] [reason] Lock a file or function
|
|
10
|
+
* scopelock unlock <file>[:<func>] <reason> Unlock with mandatory reason
|
|
11
|
+
* scopelock context [task] Generate AI context block
|
|
12
|
+
* scopelock check Verify git diff ā exits 1 on violation
|
|
13
|
+
* scopelock status Print manifest summary
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const manifest = require('../src/manifest');
|
|
17
|
+
const context = require('../src/context');
|
|
18
|
+
const git = require('../src/git');
|
|
19
|
+
|
|
20
|
+
const [,, command, ...args] = process.argv;
|
|
21
|
+
|
|
22
|
+
switch (command) {
|
|
23
|
+
|
|
24
|
+
case 'init':
|
|
25
|
+
manifest.init();
|
|
26
|
+
break;
|
|
27
|
+
|
|
28
|
+
case 'lock': {
|
|
29
|
+
const target = args[0];
|
|
30
|
+
const reason = args.slice(1).join(' ') || 'manually locked';
|
|
31
|
+
if (!target) {
|
|
32
|
+
console.error('Usage: scopelock lock <file>[:<function>] [reason]');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
manifest.lock(target, reason);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'unlock': {
|
|
40
|
+
const target = args[0];
|
|
41
|
+
const reason = args.slice(1).join(' ');
|
|
42
|
+
if (!target || !reason) {
|
|
43
|
+
console.error('Usage: scopelock unlock <file>[:<function>] <reason>');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
manifest.unlock(target, reason);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case 'context': {
|
|
51
|
+
const task = args.join(' ');
|
|
52
|
+
context.generate(task);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'check':
|
|
57
|
+
git.check();
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case 'status':
|
|
61
|
+
manifest.status();
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
console.log(`
|
|
66
|
+
scopelock ā Anti-hallucination scope locking for AI coding agents.
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
scopelock init Scan repo and generate .scopelock.json
|
|
70
|
+
scopelock lock <file>[:<func>] [reason] Lock a file or a specific function
|
|
71
|
+
scopelock unlock <file>[:<func>] <reason> Unlock (reason is mandatory)
|
|
72
|
+
scopelock context [task] Generate AI context block for a task
|
|
73
|
+
scopelock check Check git diff for scope violations
|
|
74
|
+
scopelock status Show manifest summary
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
scopelock lock src/auth.ts
|
|
78
|
+
scopelock lock src/auth.ts:validateToken "stable ā do not touch"
|
|
79
|
+
scopelock unlock src/auth.ts:validateToken "need to fix JWT expiry bug"
|
|
80
|
+
scopelock context "Fix the broken checkout flow"
|
|
81
|
+
scopelock check
|
|
82
|
+
`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manukyalo/scopelock",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Anti-hallucination scope locking for AI coding agents.",
|
|
5
|
+
"main": "bin/scopelock.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"scopelock": "bin/scopelock.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node test/run.js"
|
|
11
|
+
},
|
|
12
|
+
"author": "Emmanuel",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"skills": "./skills"
|
|
15
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scope-enforcement
|
|
3
|
+
description: "Prevents AI agent scope creep by enforcing scopelock boundaries before, during, and after every coding task. Supports file-level and function-level locking. Use before writing any code, before every commit, and before any review or ship phase. Requires scopelock CLI (npm install -g @manukyalo/scopelock)."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
AI coding agents default to the shortest path to a passing result ā which frequently means modifying files, functions, and logic far outside the declared task. `scope-enforcement` is the guardrail that stops this before it reaches a commit.
|
|
9
|
+
|
|
10
|
+
This skill uses `scopelock` ā a zero-dependency Node.js CLI ā to enforce two tiers of scope protection:
|
|
11
|
+
|
|
12
|
+
- **File-level:** An entire file is locked. No agent may touch it.
|
|
13
|
+
- **Function-level:** A specific function within a file is locked. The file may be edited, but that function's body is off-limits. `scopelock check` detects if any changed line falls inside the locked function's boundaries.
|
|
14
|
+
|
|
15
|
+
The skill operates at three checkpoints: **Session Start**, **Pre-Commit**, and **Post-Task Review**.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## When to Use
|
|
20
|
+
|
|
21
|
+
Activate this skill whenever:
|
|
22
|
+
- Starting a new coding task in any AI-assisted session.
|
|
23
|
+
- An agent is about to generate, modify, or delete any file.
|
|
24
|
+
- Running a `/review` or `/ship` command.
|
|
25
|
+
- You suspect an agent has drifted outside the declared task scope.
|
|
26
|
+
- Onboarding a new agent to an existing codebase with stable, tested modules.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Process
|
|
31
|
+
|
|
32
|
+
### Checkpoint 1 ā Session Start (Before writing any code)
|
|
33
|
+
|
|
34
|
+
**Step 1: Verify scopelock is initialized.**
|
|
35
|
+
```bash
|
|
36
|
+
scopelock status
|
|
37
|
+
```
|
|
38
|
+
If no manifest exists:
|
|
39
|
+
```bash
|
|
40
|
+
scopelock init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Step 2: Inject scope context into the agent.**
|
|
44
|
+
```bash
|
|
45
|
+
scopelock context "<task description>"
|
|
46
|
+
```
|
|
47
|
+
Paste the full output at the top of your agent's context window before writing a single line of code.
|
|
48
|
+
|
|
49
|
+
**Step 3: Lock your stable code (if not already done).**
|
|
50
|
+
|
|
51
|
+
Lock a whole file:
|
|
52
|
+
```bash
|
|
53
|
+
scopelock lock src/lib/supabase.ts "production client ā stable"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Lock a specific function (JS, TS, Python):
|
|
57
|
+
```bash
|
|
58
|
+
scopelock lock src/auth/token.ts:validateToken "tested, do not touch"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### Checkpoint 2 ā Pre-Commit (Before every `git commit`)
|
|
64
|
+
|
|
65
|
+
**Step 4: Run the scope check.**
|
|
66
|
+
```bash
|
|
67
|
+
scopelock check
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Two-tier enforcement:
|
|
71
|
+
|
|
72
|
+
| Tier | What it detects |
|
|
73
|
+
|------|----------------|
|
|
74
|
+
| File-level | Any file in the diff whose status is `locked` |
|
|
75
|
+
| Function-level | Any changed line falling inside a locked function's body |
|
|
76
|
+
|
|
77
|
+
- Exit `0` ā commit is clean. Proceed.
|
|
78
|
+
- Exit `1` ā violation found. **Stop. Do not commit.**
|
|
79
|
+
|
|
80
|
+
**Step 5: Handle violations.**
|
|
81
|
+
|
|
82
|
+
*Unintentional (scope creep):*
|
|
83
|
+
```bash
|
|
84
|
+
git restore <file>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
*Genuinely required change:*
|
|
88
|
+
```bash
|
|
89
|
+
scopelock unlock src/auth/token.ts:validateToken "fixing JWT expiry edge case"
|
|
90
|
+
scopelock check # must pass before committing
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### Checkpoint 3 ā Post-Task Review
|
|
96
|
+
|
|
97
|
+
**Step 6: Audit the unlock history.**
|
|
98
|
+
Open `.scopelock.json` and review every `history` entry. Every unlock must have a specific, task-justified reason. Vague entries like "needed it" indicate unreviewed scope creep.
|
|
99
|
+
|
|
100
|
+
**Step 7: Re-lock completed code.**
|
|
101
|
+
```bash
|
|
102
|
+
scopelock lock src/auth/token.ts:validateToken "fixed and re-locked"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Pre-commit Hook (Wire once, enforce forever)
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
echo '#!/bin/sh
|
|
111
|
+
scopelock check' > .git/hooks/pre-commit
|
|
112
|
+
chmod +x .git/hooks/pre-commit
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Common Rationalizations
|
|
118
|
+
|
|
119
|
+
| Excuse | Rebuttal |
|
|
120
|
+
|--------|----------|
|
|
121
|
+
| "The locked function only had a minor change." | Size is irrelevant. Run `git restore` or use `scopelock unlock <file>:<func>` with a real reason. |
|
|
122
|
+
| "I'll check scope after I finish the implementation." | Checking after the fact means the violation already happened. Start with `scopelock context`. |
|
|
123
|
+
| "The agent said it needed to modify that function." | Then re-evaluate scope, not the lock. If you can't write a specific reason, the modification isn't justified. |
|
|
124
|
+
| "Function-level locking is overkill." | File-level locking doesn't stop an agent from rewriting a critical function inside a file marked `active`. |
|
|
125
|
+
| "The parser didn't detect my function." | Use file-level locking instead. Flag the miss ā it's a parser bug, not a reason to skip protection. |
|
|
126
|
+
| "Running `scopelock check` slows my commit flow." | Under 300ms. The alternative is debugging a hallucinated refactor for hours. |
|
|
127
|
+
| ".scopelock.json shouldn't be committed." | Commit it. It's shared project state ā your whole team and their agents benefit. |
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Red Flags
|
|
132
|
+
|
|
133
|
+
- `scopelock check` reports violations in more than 2 files ā significant scope drift.
|
|
134
|
+
- An unlock entry has a vague reason string (e.g., "fix", "update") ā bypassed without thinking.
|
|
135
|
+
- The agent proposes unlocking multiple files at once with one generic reason ā batch rationalization.
|
|
136
|
+
- A locked function is reported as `function-missing` after a diff ā it was deleted or renamed.
|
|
137
|
+
- `scopelock status` shows 0 locked files when you expected protection ā manifest may be stale or deleted.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Verification
|
|
142
|
+
|
|
143
|
+
A session is compliant when ALL of the following are true:
|
|
144
|
+
|
|
145
|
+
- [ ] `.scopelock.json` exists and `scopelock status` shows the expected locks.
|
|
146
|
+
- [ ] `scopelock context "<task>"` output was injected into the agent before any code was written.
|
|
147
|
+
- [ ] `scopelock check` exits `0` before every commit in this session.
|
|
148
|
+
- [ ] Every `unlock` entry has a specific, task-justified reason string.
|
|
149
|
+
- [ ] No diff contains modifications to locked files or lines inside locked function bodies that were not explicitly unlocked.
|
|
150
|
+
- [ ] All modified files and functions have been reviewed for re-locking post-task.
|
|
151
|
+
|
|
152
|
+
"Seems like it probably didn't touch anything important" is not verification. Run the check.
|
package/src/context.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/context.js
|
|
5
|
+
*
|
|
6
|
+
* Generates a condensed, token-efficient AI context block.
|
|
7
|
+
* The output is designed to be pasted into any AI agent's context window.
|
|
8
|
+
* V2: Surfaces function-level locks alongside file-level locks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { getManifest } = require('./manifest');
|
|
12
|
+
|
|
13
|
+
function generate(task) {
|
|
14
|
+
const manifest = getManifest();
|
|
15
|
+
const files = Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b));
|
|
16
|
+
|
|
17
|
+
const locked = files.filter(([, v]) => v.status === 'locked');
|
|
18
|
+
const active = files.filter(([, v]) => v.status === 'active');
|
|
19
|
+
|
|
20
|
+
// Files that are unscoped but contain locked functions
|
|
21
|
+
const fnLocked = files.filter(([, v]) => {
|
|
22
|
+
if (v.status === 'locked') return false; // already captured above
|
|
23
|
+
if (!v.functions) return false;
|
|
24
|
+
return Object.values(v.functions).some(f => f.status === 'locked');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
console.log('='.repeat(60));
|
|
28
|
+
console.log('AI AGENT SCOPE CONTEXT ā DO NOT IGNORE THESE CONSTRAINTS');
|
|
29
|
+
console.log('='.repeat(60));
|
|
30
|
+
|
|
31
|
+
if (task) {
|
|
32
|
+
console.log(`\nTask: ${task}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log('\n--- LOCKED (DO NOT MODIFY) ---');
|
|
36
|
+
if (locked.length === 0 && fnLocked.length === 0) {
|
|
37
|
+
console.log(' (none)');
|
|
38
|
+
}
|
|
39
|
+
for (const [filePath, data] of locked) {
|
|
40
|
+
console.log(` [LOCKED FILE] ${filePath}`);
|
|
41
|
+
if (data.functions) {
|
|
42
|
+
for (const [fnName, fnData] of Object.entries(data.functions)) {
|
|
43
|
+
if (fnData.status === 'locked') {
|
|
44
|
+
console.log(` āāā [LOCKED FUNCTION] ${fnName}()`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const [filePath, data] of fnLocked) {
|
|
50
|
+
console.log(` [FILE ā partial lock] ${filePath}`);
|
|
51
|
+
for (const [fnName, fnData] of Object.entries(data.functions)) {
|
|
52
|
+
if (fnData.status === 'locked') {
|
|
53
|
+
console.log(` āāā [LOCKED FUNCTION] ${fnName}() ā DO NOT MODIFY`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('\n--- ACTIVE (In scope for this task) ---');
|
|
59
|
+
if (active.length === 0) {
|
|
60
|
+
console.log(' (none declared ā use `scopelock lock` to classify files)');
|
|
61
|
+
}
|
|
62
|
+
for (const [filePath] of active) {
|
|
63
|
+
console.log(` [ACTIVE] ${filePath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('\n' + '='.repeat(60));
|
|
67
|
+
console.log('INSTRUCTIONS FOR THIS SESSION:');
|
|
68
|
+
console.log('1. You MUST NOT modify any [LOCKED FILE] or [LOCKED FUNCTION].');
|
|
69
|
+
console.log('2. If a locked file or function genuinely needs to change,');
|
|
70
|
+
console.log(' run: scopelock unlock <file>[:<function>] "<reason>"');
|
|
71
|
+
console.log('3. Before committing, run: scopelock check');
|
|
72
|
+
console.log('4. Scope creep = silent regressions. Stay in bounds.');
|
|
73
|
+
console.log('='.repeat(60) + '\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { generate };
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/diff.js
|
|
5
|
+
*
|
|
6
|
+
* Parses `git diff HEAD -- <file>` output to extract the line numbers
|
|
7
|
+
* (1-indexed, in the NEW version of the file) that were added or changed.
|
|
8
|
+
*
|
|
9
|
+
* Returned as a Set<number> for O(1) membership tests.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { execSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
// Matches hunk header: @@ -a,b +c,d @@
|
|
15
|
+
// We only care about the new-file start (group 1) and optional length (group 2).
|
|
16
|
+
const HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} filePath
|
|
20
|
+
* @returns {Set<number>} 1-indexed line numbers that changed in the new file.
|
|
21
|
+
*/
|
|
22
|
+
function getChangedLines(filePath) {
|
|
23
|
+
let diffOutput;
|
|
24
|
+
try {
|
|
25
|
+
// Use -- to prevent ambiguity between file names and git flags.
|
|
26
|
+
diffOutput = execSync(`git diff HEAD -- "${filePath}"`, {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
stdio: ['pipe', 'pipe', 'ignore'], // suppress git's stderr in normal use
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
return new Set();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!diffOutput.trim()) return new Set();
|
|
35
|
+
|
|
36
|
+
const changedLines = new Set();
|
|
37
|
+
const lines = diffOutput.split('\n');
|
|
38
|
+
let currentLine = 0;
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const hunkMatch = line.match(HUNK_HEADER_RE);
|
|
42
|
+
if (hunkMatch) {
|
|
43
|
+
currentLine = parseInt(hunkMatch[1], 10);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (line.startsWith('+++') || line.startsWith('---')) {
|
|
48
|
+
// File header lines ā ignore
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (line.startsWith('+')) {
|
|
53
|
+
// Added/changed line in the new file
|
|
54
|
+
changedLines.add(currentLine);
|
|
55
|
+
currentLine++;
|
|
56
|
+
} else if (line.startsWith('-')) {
|
|
57
|
+
// Deleted line ā does NOT advance the new-file line counter
|
|
58
|
+
} else if (!line.startsWith('\\')) {
|
|
59
|
+
// Context line (unchanged) ā advances both counters
|
|
60
|
+
currentLine++;
|
|
61
|
+
}
|
|
62
|
+
// `` lines are ignored
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return changedLines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { getChangedLines };
|
package/src/git.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/git.js
|
|
5
|
+
*
|
|
6
|
+
* Scope violation checker ā V2.
|
|
7
|
+
*
|
|
8
|
+
* Two tiers of enforcement:
|
|
9
|
+
* 1. File-level: Any changed file whose manifest status is 'locked' ā violation.
|
|
10
|
+
* 2. Function-level: Any changed file that has locked functions ā
|
|
11
|
+
* parse the diff for changed line numbers, re-extract
|
|
12
|
+
* function boundaries from the current file, and flag
|
|
13
|
+
* any changed line that falls inside a locked function.
|
|
14
|
+
*
|
|
15
|
+
* Exits 1 if any violation found. Wireable as a pre-commit hook.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
const { getManifest } = require('./manifest');
|
|
20
|
+
const { getChangedLines } = require('./diff');
|
|
21
|
+
const { extractFunctions } = require('./parser');
|
|
22
|
+
|
|
23
|
+
function check() {
|
|
24
|
+
const manifest = getManifest();
|
|
25
|
+
let diffOutput;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
diffOutput = execSync('git diff HEAD --name-only', {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('git error ā are you inside a git repository with at least one commit?');
|
|
34
|
+
console.error(err.message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const changedFiles = diffOutput
|
|
39
|
+
.split('\n')
|
|
40
|
+
.map(f => f.trim())
|
|
41
|
+
.filter(f => f.length > 0);
|
|
42
|
+
|
|
43
|
+
if (changedFiles.length === 0) {
|
|
44
|
+
console.log('ā
Scope check passed ā no changes detected.');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const violations = [];
|
|
49
|
+
|
|
50
|
+
for (const file of changedFiles) {
|
|
51
|
+
const normalizedFile = file.replace(/\\/g, '/');
|
|
52
|
+
const entry = manifest.files[normalizedFile];
|
|
53
|
+
|
|
54
|
+
// āā Tier 1: File-level lock āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
55
|
+
if (entry && entry.status === 'locked') {
|
|
56
|
+
violations.push({
|
|
57
|
+
type: 'file',
|
|
58
|
+
file: normalizedFile,
|
|
59
|
+
message: `File '${normalizedFile}' is LOCKED.`,
|
|
60
|
+
});
|
|
61
|
+
continue; // No need to check functions if the whole file is locked
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// āā Tier 2: Function-level lock āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
65
|
+
if (!entry || !entry.functions) continue;
|
|
66
|
+
|
|
67
|
+
const lockedFunctions = Object.entries(entry.functions)
|
|
68
|
+
.filter(([, fnData]) => fnData.status === 'locked')
|
|
69
|
+
.map(([name]) => name);
|
|
70
|
+
|
|
71
|
+
if (lockedFunctions.length === 0) continue;
|
|
72
|
+
|
|
73
|
+
// Get the line numbers that changed in this specific file
|
|
74
|
+
const changedLines = getChangedLines(normalizedFile);
|
|
75
|
+
if (changedLines.size === 0) continue;
|
|
76
|
+
|
|
77
|
+
// Re-extract function boundaries from the current on-disk file
|
|
78
|
+
const currentFunctions = extractFunctions(normalizedFile);
|
|
79
|
+
|
|
80
|
+
for (const lockedFnName of lockedFunctions) {
|
|
81
|
+
const fn = currentFunctions.find(f => f.name === lockedFnName);
|
|
82
|
+
if (!fn) {
|
|
83
|
+
// Function was deleted or renamed ā this itself is a violation
|
|
84
|
+
violations.push({
|
|
85
|
+
type: 'function-missing',
|
|
86
|
+
file: normalizedFile,
|
|
87
|
+
fn: lockedFnName,
|
|
88
|
+
message: `Locked function '${lockedFnName}' in '${normalizedFile}' was removed or renamed.`,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check if any changed line falls within the function's boundaries
|
|
94
|
+
for (const line of changedLines) {
|
|
95
|
+
if (line >= fn.startLine && line <= fn.endLine) {
|
|
96
|
+
violations.push({
|
|
97
|
+
type: 'function',
|
|
98
|
+
file: normalizedFile,
|
|
99
|
+
fn: lockedFnName,
|
|
100
|
+
message:
|
|
101
|
+
`Locked function '${lockedFnName}' in '${normalizedFile}' was modified ` +
|
|
102
|
+
`(changed line ${line} is inside [${fn.startLine}ā${fn.endLine}]).`,
|
|
103
|
+
});
|
|
104
|
+
break; // One violation per function is enough
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// āā Report āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
111
|
+
if (violations.length === 0) {
|
|
112
|
+
console.log('ā
Scope check passed ā no locked files or functions were modified.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.error(`\nā Scope violations detected:\n`);
|
|
117
|
+
for (const v of violations) {
|
|
118
|
+
console.error(` VIOLATION: ${v.message}`);
|
|
119
|
+
}
|
|
120
|
+
console.error(
|
|
121
|
+
`\n${violations.length} violation(s) found.\n` +
|
|
122
|
+
` ⢠Revert unintentional changes with: git restore <file>\n` +
|
|
123
|
+
` ⢠Explicitly unlock with: scopelock unlock <file>[:<function>] "<reason>"`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { check };
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/manifest.js
|
|
5
|
+
*
|
|
6
|
+
* All reads and writes to .scopelock.json go through this module.
|
|
7
|
+
*
|
|
8
|
+
* Manifest schema (V2):
|
|
9
|
+
* {
|
|
10
|
+
* "version": 2,
|
|
11
|
+
* "files": {
|
|
12
|
+
* "src/auth.ts": {
|
|
13
|
+
* "status": "unscoped" | "locked" | "active",
|
|
14
|
+
* "functions": { // populated by `scopelock lock`
|
|
15
|
+
* "validateToken": {
|
|
16
|
+
* "status": "locked" | "active",
|
|
17
|
+
* "history": [{ timestamp, action, reason }]
|
|
18
|
+
* }
|
|
19
|
+
* },
|
|
20
|
+
* "history": [{ timestamp, action, reason }]
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* V1 manifests (no "version" / no "functions" key) are read transparently.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const { extractFunctions, detectLanguage } = require('./parser');
|
|
32
|
+
|
|
33
|
+
const MANIFEST_FILE = '.scopelock.json';
|
|
34
|
+
const VERSION = 2;
|
|
35
|
+
|
|
36
|
+
// āāā Ignored directories āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
37
|
+
|
|
38
|
+
const IGNORED_DIRS = new Set([
|
|
39
|
+
'node_modules', '.git', '.next', '.nuxt', '.turbo',
|
|
40
|
+
'.vercel', '.expo', 'dist', 'build', 'out',
|
|
41
|
+
'coverage', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
42
|
+
'target', '.gradle', '.idea', '.vscode',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// āāā File walker āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
46
|
+
|
|
47
|
+
function walkDir(dir, fileList = []) {
|
|
48
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
49
|
+
if (IGNORED_DIRS.has(entry)) continue;
|
|
50
|
+
const fullPath = path.join(dir, entry);
|
|
51
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
52
|
+
walkDir(fullPath, fileList);
|
|
53
|
+
} else {
|
|
54
|
+
fileList.push(fullPath);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return fileList;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// āāā Manifest I/O āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
61
|
+
|
|
62
|
+
function getManifest() {
|
|
63
|
+
if (!fs.existsSync(MANIFEST_FILE)) {
|
|
64
|
+
console.error(`No ${MANIFEST_FILE} found. Run 'scopelock init' first.`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function saveManifest(data) {
|
|
71
|
+
fs.writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ensure a file entry exists in the manifest, creating it if needed.
|
|
75
|
+
function ensureFileEntry(manifest, relativePath) {
|
|
76
|
+
if (!manifest.files[relativePath]) {
|
|
77
|
+
manifest.files[relativePath] = { status: 'unscoped', functions: {}, history: [] };
|
|
78
|
+
}
|
|
79
|
+
if (!manifest.files[relativePath].functions) {
|
|
80
|
+
manifest.files[relativePath].functions = {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// āāā Commands āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
85
|
+
|
|
86
|
+
function init() {
|
|
87
|
+
if (fs.existsSync(MANIFEST_FILE)) {
|
|
88
|
+
console.log(`.scopelock.json already exists. Use 'scopelock status' to view it.`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const files = walkDir('.');
|
|
93
|
+
const manifest = { version: VERSION, files: {} };
|
|
94
|
+
let count = 0;
|
|
95
|
+
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
const relativePath = path.relative('.', f).replace(/\\/g, '/');
|
|
98
|
+
if (relativePath === MANIFEST_FILE) continue;
|
|
99
|
+
manifest.files[relativePath] = { status: 'unscoped', functions: {}, history: [] };
|
|
100
|
+
count++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
saveManifest(manifest);
|
|
104
|
+
console.log(`ā
Initialized ${MANIFEST_FILE} ā ${count} source files tracked.`);
|
|
105
|
+
console.log(` Run 'scopelock lock <file>' to protect files or functions.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lock a file or a specific function within a file.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} target "<file>" or "<file>:<functionName>"
|
|
112
|
+
* @param {string} [reason] Optional reason (defaults to "manually locked")
|
|
113
|
+
*/
|
|
114
|
+
function lock(target, reason = 'manually locked') {
|
|
115
|
+
const [filePart, funcName] = target.split(':');
|
|
116
|
+
const relativePath = filePart.replace(/\\/g, '/');
|
|
117
|
+
|
|
118
|
+
const manifest = getManifest();
|
|
119
|
+
ensureFileEntry(manifest, relativePath);
|
|
120
|
+
const entry = manifest.files[relativePath];
|
|
121
|
+
|
|
122
|
+
const historyEntry = {
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
action: 'locked',
|
|
125
|
+
reason,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (funcName) {
|
|
129
|
+
// Function-level lock ā parse the file to validate the function exists
|
|
130
|
+
if (!fs.existsSync(filePart)) {
|
|
131
|
+
console.error(`File not found: ${filePart}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const lang = detectLanguage(filePart);
|
|
136
|
+
if (!lang) {
|
|
137
|
+
console.error(
|
|
138
|
+
`Function-level locking is only supported for JS, TS, and Python files.\n` +
|
|
139
|
+
`'${filePart}' is not a supported language. Lock the whole file instead.`
|
|
140
|
+
);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fns = extractFunctions(filePart);
|
|
145
|
+
const found = fns.find(f => f.name === funcName);
|
|
146
|
+
if (!found) {
|
|
147
|
+
console.error(
|
|
148
|
+
`Function '${funcName}' not found in ${filePart}.\n` +
|
|
149
|
+
`Known functions: ${fns.map(f => f.name).join(', ') || '(none detected)'}`
|
|
150
|
+
);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
entry.functions[funcName] = {
|
|
155
|
+
status: 'locked',
|
|
156
|
+
history: [historyEntry],
|
|
157
|
+
};
|
|
158
|
+
saveManifest(manifest);
|
|
159
|
+
console.log(`š Locked function '${funcName}' in ${relativePath}.`);
|
|
160
|
+
} else {
|
|
161
|
+
// File-level lock
|
|
162
|
+
entry.status = 'locked';
|
|
163
|
+
entry.history.push(historyEntry);
|
|
164
|
+
saveManifest(manifest);
|
|
165
|
+
console.log(`š Locked ${relativePath}.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Unlock a file or a specific function.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} target "<file>" or "<file>:<functionName>"
|
|
173
|
+
* @param {string} reason Mandatory reason string for audit log.
|
|
174
|
+
*/
|
|
175
|
+
function unlock(target, reason) {
|
|
176
|
+
const [filePart, funcName] = target.split(':');
|
|
177
|
+
const relativePath = filePart.replace(/\\/g, '/');
|
|
178
|
+
|
|
179
|
+
const manifest = getManifest();
|
|
180
|
+
ensureFileEntry(manifest, relativePath);
|
|
181
|
+
const entry = manifest.files[relativePath];
|
|
182
|
+
|
|
183
|
+
const historyEntry = {
|
|
184
|
+
timestamp: new Date().toISOString(),
|
|
185
|
+
action: 'unlocked',
|
|
186
|
+
reason,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (funcName) {
|
|
190
|
+
if (!entry.functions[funcName]) {
|
|
191
|
+
entry.functions[funcName] = { status: 'active', history: [] };
|
|
192
|
+
}
|
|
193
|
+
entry.functions[funcName].status = 'active';
|
|
194
|
+
entry.functions[funcName].history.push(historyEntry);
|
|
195
|
+
saveManifest(manifest);
|
|
196
|
+
console.log(`š Unlocked function '${funcName}' in ${relativePath}. Reason: ${reason}`);
|
|
197
|
+
} else {
|
|
198
|
+
entry.status = 'active';
|
|
199
|
+
entry.history.push(historyEntry);
|
|
200
|
+
saveManifest(manifest);
|
|
201
|
+
console.log(`š Unlocked ${relativePath}. Reason: ${reason}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Print a human-readable summary of the current manifest state.
|
|
207
|
+
*/
|
|
208
|
+
function status() {
|
|
209
|
+
const manifest = getManifest();
|
|
210
|
+
const files = Object.entries(manifest.files);
|
|
211
|
+
|
|
212
|
+
const locked = files.filter(([, v]) => v.status === 'locked');
|
|
213
|
+
const active = files.filter(([, v]) => v.status === 'active');
|
|
214
|
+
const unscoped = files.filter(([, v]) => v.status === 'unscoped');
|
|
215
|
+
|
|
216
|
+
// Count locked functions across all files
|
|
217
|
+
let lockedFnCount = 0;
|
|
218
|
+
for (const [, v] of files) {
|
|
219
|
+
if (!v.functions) continue;
|
|
220
|
+
lockedFnCount += Object.values(v.functions).filter(f => f.status === 'locked').length;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
console.log(`\nš scopelock status\n`);
|
|
224
|
+
console.log(` š locked ā ${locked.length} file(s), ${lockedFnCount} function(s)`);
|
|
225
|
+
console.log(` āļø active ā ${active.length} file(s)`);
|
|
226
|
+
console.log(` ⬠unscoped ā ${unscoped.length} file(s)\n`);
|
|
227
|
+
|
|
228
|
+
if (locked.length > 0) {
|
|
229
|
+
console.log(`Locked files:`);
|
|
230
|
+
for (const [filePath, data] of locked) {
|
|
231
|
+
console.log(` ${filePath}`);
|
|
232
|
+
if (data.functions) {
|
|
233
|
+
for (const [fnName, fnData] of Object.entries(data.functions)) {
|
|
234
|
+
if (fnData.status === 'locked') {
|
|
235
|
+
console.log(` āāā ${fnName}() [locked]`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (active.length > 0) {
|
|
243
|
+
console.log(`\nActive (in-scope for current task):`);
|
|
244
|
+
for (const [filePath, data] of active) {
|
|
245
|
+
console.log(` ${filePath}`);
|
|
246
|
+
if (data.functions) {
|
|
247
|
+
for (const [fnName, fnData] of Object.entries(data.functions)) {
|
|
248
|
+
if (fnData.status === 'locked') {
|
|
249
|
+
console.log(` āāā ${fnName}() [locked]`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log('');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = { init, lock, unlock, status, getManifest, saveManifest };
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* src/parser.js
|
|
5
|
+
*
|
|
6
|
+
* Language-aware function/class extractor.
|
|
7
|
+
* No external dependencies ā uses line-by-line regex matching
|
|
8
|
+
* + brace-depth counting for JS/TS and indentation tracking for Python.
|
|
9
|
+
*
|
|
10
|
+
* Returns: Array<{ name: string, startLine: number, endLine: number }>
|
|
11
|
+
* All line numbers are 1-indexed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
// Extensions we know how to parse. Everything else gets file-level locking only.
|
|
18
|
+
const EXTENSION_TO_LANG = {
|
|
19
|
+
'.js': 'javascript',
|
|
20
|
+
'.jsx': 'javascript',
|
|
21
|
+
'.ts': 'typescript',
|
|
22
|
+
'.tsx': 'typescript',
|
|
23
|
+
'.mjs': 'javascript',
|
|
24
|
+
'.cjs': 'javascript',
|
|
25
|
+
'.py': 'python',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// JS / TS patterns ā each must capture the function/class name in group 1.
|
|
29
|
+
// Order matters: more specific patterns first.
|
|
30
|
+
const JS_PATTERNS = [
|
|
31
|
+
// export async function foo( / export function foo( / function foo(
|
|
32
|
+
/^[\t ]*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/,
|
|
33
|
+
// export class Foo / class Foo
|
|
34
|
+
/^[\t ]*(?:export\s+)?(?:default\s+)?class\s+(\w+)/,
|
|
35
|
+
// export const foo = async ( / const foo = ( / const foo = function(
|
|
36
|
+
/^[\t ]*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\(|function\s*[({(])/,
|
|
37
|
+
// Class method shorthand: foo() { / async foo() {
|
|
38
|
+
/^[\t ]+(?:(?:static|async|get|set|public|private|protected|override|abstract)\s+)*(\w+)\s*\([^)]*\)\s*(?::\s*\S+\s*)?\{/,
|
|
39
|
+
// Object method shorthand or TypeScript interface method
|
|
40
|
+
/^[\t ]+(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[\w<>[\]|&, ]+\s*)?\{/,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const PY_PATTERNS = [
|
|
44
|
+
/^[\t ]*(?:async\s+)?def\s+(\w+)\s*\(/,
|
|
45
|
+
/^[\t ]*class\s+(\w+)/,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// āāā Language detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
49
|
+
|
|
50
|
+
function detectLanguage(filePath) {
|
|
51
|
+
return EXTENSION_TO_LANG[path.extname(filePath).toLowerCase()] || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// āāā End-of-function detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walk forward from startLine to find where this function/class ends.
|
|
58
|
+
* Returns a 0-indexed line number.
|
|
59
|
+
*/
|
|
60
|
+
function findFunctionEnd(lines, startLine, lang) {
|
|
61
|
+
if (lang === 'python') {
|
|
62
|
+
// Python: end when indentation returns to the level of the def/class line
|
|
63
|
+
// or we hit EOF.
|
|
64
|
+
const defLine = lines[startLine];
|
|
65
|
+
const baseIndent = (defLine.match(/^(\s*)/) || ['', ''])[1].length;
|
|
66
|
+
|
|
67
|
+
for (let i = startLine + 1; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i];
|
|
69
|
+
if (!line.trim()) continue; // skip blank lines
|
|
70
|
+
const indent = (line.match(/^(\s*)/) || ['', ''])[1].length;
|
|
71
|
+
if (indent <= baseIndent) return i - 1;
|
|
72
|
+
}
|
|
73
|
+
return lines.length - 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// JS / TS: count braces from the declaration line forward.
|
|
77
|
+
// Caveat: this is naive ā string literals and block comments with braces
|
|
78
|
+
// can confuse the counter, but it covers the vast majority of real code.
|
|
79
|
+
let depth = 0;
|
|
80
|
+
let foundOpen = false;
|
|
81
|
+
|
|
82
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
83
|
+
for (const ch of lines[i]) {
|
|
84
|
+
if (ch === '{') { depth++; foundOpen = true; }
|
|
85
|
+
else if (ch === '}') depth--;
|
|
86
|
+
}
|
|
87
|
+
if (foundOpen && depth === 0) return i;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return lines.length - 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// āāā Public API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract all top-level and class-level function/method/class declarations
|
|
97
|
+
* from a source file.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} filePath Absolute or relative path to a source file.
|
|
100
|
+
* @returns {Array<{name: string, startLine: number, endLine: number}>}
|
|
101
|
+
*/
|
|
102
|
+
function extractFunctions(filePath) {
|
|
103
|
+
const lang = detectLanguage(filePath);
|
|
104
|
+
if (!lang) return [];
|
|
105
|
+
|
|
106
|
+
let content;
|
|
107
|
+
try {
|
|
108
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines = content.split('\n');
|
|
114
|
+
const patterns = lang === 'python' ? PY_PATTERNS : JS_PATTERNS;
|
|
115
|
+
const results = [];
|
|
116
|
+
|
|
117
|
+
// Track the last end line to avoid duplicate matches inside the same block
|
|
118
|
+
let lastEnd = -1;
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
if (i <= lastEnd) continue; // already inside a block we catalogued
|
|
122
|
+
|
|
123
|
+
for (const pattern of patterns) {
|
|
124
|
+
const match = lines[i].match(pattern);
|
|
125
|
+
if (match && match[1]) {
|
|
126
|
+
const endLine = findFunctionEnd(lines, i, lang);
|
|
127
|
+
results.push({
|
|
128
|
+
name: match[1],
|
|
129
|
+
startLine: i + 1, // 1-indexed
|
|
130
|
+
endLine: endLine + 1, // 1-indexed
|
|
131
|
+
});
|
|
132
|
+
lastEnd = endLine;
|
|
133
|
+
break; // only one pattern fires per line
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { extractFunctions, detectLanguage };
|
package/test/run.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* test/run.js
|
|
5
|
+
*
|
|
6
|
+
* Integration tests for scopelock V2.
|
|
7
|
+
* Sets up a real git repo in test/tmp_repo/, exercises all commands,
|
|
8
|
+
* and asserts correct exit codes and output.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// āāā Setup āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
16
|
+
|
|
17
|
+
const testDir = path.join(__dirname, 'tmp_repo');
|
|
18
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
19
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
20
|
+
process.chdir(testDir);
|
|
21
|
+
|
|
22
|
+
const CLI = 'node ../../bin/scopelock.js';
|
|
23
|
+
|
|
24
|
+
function run(cmd, expectFail = false) {
|
|
25
|
+
try {
|
|
26
|
+
return execSync(cmd, { encoding: 'utf8', stdio: 'pipe' });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (expectFail) return err.stderr + err.stdout;
|
|
29
|
+
console.error(`\nUnexpected failure running: ${cmd}`);
|
|
30
|
+
console.error(err.stdout);
|
|
31
|
+
console.error(err.stderr);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assert(condition, message) {
|
|
37
|
+
if (!condition) {
|
|
38
|
+
console.error(`\n FAIL: ${message}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
console.log(` ā ${message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// āāā Git repo bootstrap āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
45
|
+
|
|
46
|
+
run('git init');
|
|
47
|
+
run('git config user.email "test@scopelock.dev"');
|
|
48
|
+
run('git config user.name "scopelock test"');
|
|
49
|
+
|
|
50
|
+
// Write a simple JS file with two named functions
|
|
51
|
+
fs.writeFileSync('app.js', `
|
|
52
|
+
function stableFunc() {
|
|
53
|
+
return 'I am stable and tested';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function workInProgress() {
|
|
57
|
+
return 'I am actively being edited';
|
|
58
|
+
}
|
|
59
|
+
`.trimStart());
|
|
60
|
+
|
|
61
|
+
fs.writeFileSync('readme.txt', 'Initial readme content.\n');
|
|
62
|
+
run('git add .');
|
|
63
|
+
run('git commit -m "initial"');
|
|
64
|
+
|
|
65
|
+
// āāā Tests āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
66
|
+
|
|
67
|
+
console.log('\n--- Test 1: scopelock init ---');
|
|
68
|
+
run(`${CLI} init`);
|
|
69
|
+
assert(fs.existsSync('.scopelock.json'), '.scopelock.json was created');
|
|
70
|
+
const manifest = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
71
|
+
assert(manifest.version === 2, 'Manifest is V2 schema');
|
|
72
|
+
assert(manifest.files['app.js'] !== undefined, 'app.js is tracked');
|
|
73
|
+
assert(manifest.files['readme.txt'] !== undefined, 'readme.txt is tracked');
|
|
74
|
+
|
|
75
|
+
console.log('\n--- Test 2: scopelock status ---');
|
|
76
|
+
const statusOut = run(`${CLI} status`);
|
|
77
|
+
assert(statusOut.includes('unscoped'), 'status shows unscoped files');
|
|
78
|
+
|
|
79
|
+
console.log('\n--- Test 3: File-level lock ---');
|
|
80
|
+
run(`${CLI} lock readme.txt "stable documentation"`);
|
|
81
|
+
const m2 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
82
|
+
assert(m2.files['readme.txt'].status === 'locked', 'readme.txt is locked');
|
|
83
|
+
|
|
84
|
+
console.log('\n--- Test 4: File-level violation detection ---');
|
|
85
|
+
fs.appendFileSync('readme.txt', 'AI hallucinated this line.\n');
|
|
86
|
+
const violation1 = run(`${CLI} check`, true);
|
|
87
|
+
assert(violation1.includes('VIOLATION'), 'check detects locked file modification');
|
|
88
|
+
|
|
89
|
+
console.log('\n--- Test 5: File-level unlock clears violation ---');
|
|
90
|
+
run(`${CLI} unlock readme.txt "intentional update to docs"`);
|
|
91
|
+
const check1 = run(`${CLI} check`);
|
|
92
|
+
assert(check1.includes('passed'), 'check passes after unlock');
|
|
93
|
+
run('git restore readme.txt'); // clean up
|
|
94
|
+
|
|
95
|
+
console.log('\n--- Test 6: Function-level lock ---');
|
|
96
|
+
run(`${CLI} lock app.js:stableFunc "tested, production-ready"`);
|
|
97
|
+
const m3 = JSON.parse(fs.readFileSync('.scopelock.json', 'utf8'));
|
|
98
|
+
assert(
|
|
99
|
+
m3.files['app.js'].functions['stableFunc'].status === 'locked',
|
|
100
|
+
'stableFunc is function-locked'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
console.log('\n--- Test 7: Change OUTSIDE locked function ā no violation ---');
|
|
104
|
+
// Modify workInProgress (line 6-8), stableFunc is locked (lines 1-3)
|
|
105
|
+
fs.writeFileSync('app.js', `
|
|
106
|
+
function stableFunc() {
|
|
107
|
+
return 'I am stable and tested';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function workInProgress() {
|
|
111
|
+
return 'actively being edited -- new change';
|
|
112
|
+
}
|
|
113
|
+
`.trimStart());
|
|
114
|
+
const check2 = run(`${CLI} check`);
|
|
115
|
+
assert(check2.includes('passed'), 'change outside locked function does not trigger violation');
|
|
116
|
+
|
|
117
|
+
console.log('\n--- Test 8: Change INSIDE locked function ā violation ---');
|
|
118
|
+
fs.writeFileSync('app.js', `
|
|
119
|
+
function stableFunc() {
|
|
120
|
+
return 'I have been hallucinated';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function workInProgress() {
|
|
124
|
+
return 'actively being edited -- new change';
|
|
125
|
+
}
|
|
126
|
+
`.trimStart());
|
|
127
|
+
const violation2 = run(`${CLI} check`, true);
|
|
128
|
+
assert(violation2.includes('VIOLATION'), 'change inside locked function triggers violation');
|
|
129
|
+
assert(violation2.includes('stableFunc'), 'violation names the locked function');
|
|
130
|
+
|
|
131
|
+
console.log('\n--- Test 9: Function unlock clears function-level violation ---');
|
|
132
|
+
run(`${CLI} unlock app.js:stableFunc "need to update return value for new API"`);
|
|
133
|
+
const check3 = run(`${CLI} check`);
|
|
134
|
+
assert(check3.includes('passed'), 'check passes after function unlock');
|
|
135
|
+
|
|
136
|
+
console.log('\n--- Test 10: Lock unknown function fails gracefully ---');
|
|
137
|
+
const badLock = run(`${CLI} lock app.js:doesNotExist "testing"`, true);
|
|
138
|
+
assert(badLock.includes('not found'), 'locking unknown function fails with clear message');
|
|
139
|
+
|
|
140
|
+
console.log('\n--- Test 11: scopelock context output ---');
|
|
141
|
+
const ctx = run(`${CLI} context "update the WIP function"`);
|
|
142
|
+
assert(ctx.includes('SCOPE CONTEXT'), 'context output contains header');
|
|
143
|
+
|
|
144
|
+
// āāā Done āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
145
|
+
|
|
146
|
+
console.log('\nā
All 11 tests passed.\n');
|