pywrit 0.1.2__tar.gz → 0.1.4__tar.gz

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.
pywrit-0.1.4/PKG-INFO ADDED
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywrit
3
+ Version: 0.1.4
4
+ Summary: Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log
5
+ Project-URL: Homepage, https://withwrit.com
6
+ Project-URL: Documentation, https://docs.withwrit.com
7
+ Keywords: agents,audit-log,authorization,api-keys
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Security
11
+ Classifier: Topic :: System :: Systems Administration
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: polyglot
16
+ Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
17
+ Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
18
+ Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
19
+ Dynamic: license-file
20
+
21
+ # pywrit
22
+
23
+ The Writ CLI — the gate before the write.
24
+
25
+ ```bash
26
+ pip install pywrit
27
+ writ key --email you@company.com
28
+ ```
29
+
30
+ **Find the consequential writes your agents can perform.** `writ scan .` is a
31
+ free, local scan (no API key) that lists the writes it finds in your agent code. Then gate
32
+ them: Writ is an allow/deny gate you put in front of your agent's writes, and each check
33
+ writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
34
+
35
+ ![writ scan finds 4 writes in a Python agent; writ scan --apply gates all of them](https://withwrit.com/assets/scan-python.gif)
36
+
37
+ ## What it does
38
+
39
+ - `writ key` — Get a free API key (1,000 receipts/month, no card)
40
+ - `writ check` — Ask the gate before a write (ALLOW or DENY)
41
+ - `writ revoke` — Kill switch: revoke a principal's tokens in one click
42
+ - `writ receipts` — See your audit log
43
+ - `writ scan` — Find writes in your repo (Python: scan + instrument; TS/JS: scan only)
44
+
45
+ ## Scanning a repo
46
+
47
+ ```bash
48
+ writ scan ./my-repo # report writes, print the diff, write writ-policy.json
49
+ writ scan ./my-repo --apply # prompt, then insert gates (Python files only)
50
+ pip install 'pywrit[polyglot]' # optional: also scan TypeScript / JavaScript
51
+ ```
52
+
53
+ ![writ scan on a TypeScript support agent: 1 of 5 write sites gated](https://withwrit.com/assets/scan-tsjs.gif)
54
+
55
+ - **Python** (`.py`): parsed with the stdlib `ast`, no extra dependencies.
56
+ Finds DB calls, HTTP clients, file writes, email, queues, and AWS SDK
57
+ writes. `writ scan --apply` inserts a `_writ_check(...)` gate at the top of
58
+ each writing function.
59
+ - **TypeScript / JavaScript** (`.ts .tsx .js .jsx .mjs .cjs`): **scan only**,
60
+ never rewritten, and needs the `polyglot` extra (tree-sitter). Without it,
61
+ `writ scan` prints a one-line install hint and keeps scanning Python.
62
+ Each finding is listed as `file:line [category] rule function(): snippet`,
63
+ and its verb goes into `writ-policy.json` like a Python finding. Detected:
64
+ - HTTP: `fetch` / `axios` / `got` / `ky` / `superagent` with a literal
65
+ `POST`/`PUT`/`PATCH`/`DELETE` method, `axios.post|put|patch|delete`,
66
+ `http(s).request` with a write method
67
+ - Files: `fs.writeFile`, `appendFile`, `unlink`, `rm`, `rename`, `mkdir`, …
68
+ plus `Sync` and `fs/promises` variants
69
+ - SQL: literal `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/… strings passed to
70
+ `query` / `execute` / `raw` (including tagged templates such as
71
+ `prisma.$executeRaw`)
72
+ - SDKs: Stripe `create|update|del` (e.g. `stripe.refunds.create`), Prisma
73
+ `create|update|upsert|delete|deleteMany|updateMany`, knex/db builders
74
+ `insert|update|del`, `child_process` `exec`/`spawn`
75
+ - A function that already calls `writCheck(...)` / `writ_check(...)` counts
76
+ as gated.
77
+ - Skipped: `node_modules`, `dist`, `build`, hidden dirs such as `.git` and
78
+ `.next`, virtualenvs, and test files. TS/JS scanning also skips `vendor`,
79
+ `*.d.ts`, `*.min.js`, and files over 1 MB, and respects `.gitignore` when
80
+ the repo is a git checkout.
81
+ - TS/JS limitations: calls are matched by name, so HTTP methods, SQL strings,
82
+ or options held in variables (`fetch(url, opts)`, `db.query(sql)`) are not
83
+ detected, and route definitions like `router.post(...)` are deliberately
84
+ not flagged. The Writ Score grades Python findings only; the
85
+ `ts/js gated: N/M` line is the TS/JS coverage number.
86
+
87
+ ## Docs
88
+
89
+ Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
90
+
91
+ ---
92
+
93
+ ## Packaging notes (for maintainers)
94
+
95
+ Installable distribution of the Writ CLI. `pip install pywrit` gives you the
96
+ `writ` command — no `curl` + `chmod` dance.
97
+
98
+ ### Layout
99
+
100
+ - `pyproject.toml` — package metadata, `writ` console-script entry point
101
+ - `writ_cli/__init__.py` — version
102
+ - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib;
103
+ TS/JS scanning imports tree-sitter only when the `polyglot` extra is installed)
104
+ - `tests/` — scanner tests: `python -m unittest discover -s tests`
105
+ - Demo GIFs: recorded with [VHS](https://github.com/charmbracelet/vhs) from
106
+ `docs/tapes/*.tape` (repo root: `docs/tapes/render.sh`)
107
+
108
+ ### Relationship to `gate/web/writ`
109
+
110
+ `gate/web/writ` is the **self-contained single-file script** served by the Lambda
111
+ at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
112
+ file with no install step.
113
+
114
+ `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
115
+ byte-identical by copy. If you change CLI behavior, update **both** and keep
116
+ them in sync — the download path and the pip path must behave the same.
117
+
118
+ ### Build & test locally
119
+
120
+ ```bash
121
+ cd cli
122
+ python -m build # needs `pip install build`
123
+ pip install -e . # editable install, creates the `writ` command
124
+ writ --help
125
+ ```
126
+
127
+ ### Publish
128
+
129
+ ```bash
130
+ cd cli
131
+ python -m build
132
+ twine upload dist/*
133
+ ```
pywrit-0.1.4/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # pywrit
2
+
3
+ The Writ CLI — the gate before the write.
4
+
5
+ ```bash
6
+ pip install pywrit
7
+ writ key --email you@company.com
8
+ ```
9
+
10
+ **Find the consequential writes your agents can perform.** `writ scan .` is a
11
+ free, local scan (no API key) that lists the writes it finds in your agent code. Then gate
12
+ them: Writ is an allow/deny gate you put in front of your agent's writes, and each check
13
+ writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
14
+
15
+ ![writ scan finds 4 writes in a Python agent; writ scan --apply gates all of them](https://withwrit.com/assets/scan-python.gif)
16
+
17
+ ## What it does
18
+
19
+ - `writ key` — Get a free API key (1,000 receipts/month, no card)
20
+ - `writ check` — Ask the gate before a write (ALLOW or DENY)
21
+ - `writ revoke` — Kill switch: revoke a principal's tokens in one click
22
+ - `writ receipts` — See your audit log
23
+ - `writ scan` — Find writes in your repo (Python: scan + instrument; TS/JS: scan only)
24
+
25
+ ## Scanning a repo
26
+
27
+ ```bash
28
+ writ scan ./my-repo # report writes, print the diff, write writ-policy.json
29
+ writ scan ./my-repo --apply # prompt, then insert gates (Python files only)
30
+ pip install 'pywrit[polyglot]' # optional: also scan TypeScript / JavaScript
31
+ ```
32
+
33
+ ![writ scan on a TypeScript support agent: 1 of 5 write sites gated](https://withwrit.com/assets/scan-tsjs.gif)
34
+
35
+ - **Python** (`.py`): parsed with the stdlib `ast`, no extra dependencies.
36
+ Finds DB calls, HTTP clients, file writes, email, queues, and AWS SDK
37
+ writes. `writ scan --apply` inserts a `_writ_check(...)` gate at the top of
38
+ each writing function.
39
+ - **TypeScript / JavaScript** (`.ts .tsx .js .jsx .mjs .cjs`): **scan only**,
40
+ never rewritten, and needs the `polyglot` extra (tree-sitter). Without it,
41
+ `writ scan` prints a one-line install hint and keeps scanning Python.
42
+ Each finding is listed as `file:line [category] rule function(): snippet`,
43
+ and its verb goes into `writ-policy.json` like a Python finding. Detected:
44
+ - HTTP: `fetch` / `axios` / `got` / `ky` / `superagent` with a literal
45
+ `POST`/`PUT`/`PATCH`/`DELETE` method, `axios.post|put|patch|delete`,
46
+ `http(s).request` with a write method
47
+ - Files: `fs.writeFile`, `appendFile`, `unlink`, `rm`, `rename`, `mkdir`, …
48
+ plus `Sync` and `fs/promises` variants
49
+ - SQL: literal `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/… strings passed to
50
+ `query` / `execute` / `raw` (including tagged templates such as
51
+ `prisma.$executeRaw`)
52
+ - SDKs: Stripe `create|update|del` (e.g. `stripe.refunds.create`), Prisma
53
+ `create|update|upsert|delete|deleteMany|updateMany`, knex/db builders
54
+ `insert|update|del`, `child_process` `exec`/`spawn`
55
+ - A function that already calls `writCheck(...)` / `writ_check(...)` counts
56
+ as gated.
57
+ - Skipped: `node_modules`, `dist`, `build`, hidden dirs such as `.git` and
58
+ `.next`, virtualenvs, and test files. TS/JS scanning also skips `vendor`,
59
+ `*.d.ts`, `*.min.js`, and files over 1 MB, and respects `.gitignore` when
60
+ the repo is a git checkout.
61
+ - TS/JS limitations: calls are matched by name, so HTTP methods, SQL strings,
62
+ or options held in variables (`fetch(url, opts)`, `db.query(sql)`) are not
63
+ detected, and route definitions like `router.post(...)` are deliberately
64
+ not flagged. The Writ Score grades Python findings only; the
65
+ `ts/js gated: N/M` line is the TS/JS coverage number.
66
+
67
+ ## Docs
68
+
69
+ Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
70
+
71
+ ---
72
+
73
+ ## Packaging notes (for maintainers)
74
+
75
+ Installable distribution of the Writ CLI. `pip install pywrit` gives you the
76
+ `writ` command — no `curl` + `chmod` dance.
77
+
78
+ ### Layout
79
+
80
+ - `pyproject.toml` — package metadata, `writ` console-script entry point
81
+ - `writ_cli/__init__.py` — version
82
+ - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib;
83
+ TS/JS scanning imports tree-sitter only when the `polyglot` extra is installed)
84
+ - `tests/` — scanner tests: `python -m unittest discover -s tests`
85
+ - Demo GIFs: recorded with [VHS](https://github.com/charmbracelet/vhs) from
86
+ `docs/tapes/*.tape` (repo root: `docs/tapes/render.sh`)
87
+
88
+ ### Relationship to `gate/web/writ`
89
+
90
+ `gate/web/writ` is the **self-contained single-file script** served by the Lambda
91
+ at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
92
+ file with no install step.
93
+
94
+ `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
95
+ byte-identical by copy. If you change CLI behavior, update **both** and keep
96
+ them in sync — the download path and the pip path must behave the same.
97
+
98
+ ### Build & test locally
99
+
100
+ ```bash
101
+ cd cli
102
+ python -m build # needs `pip install build`
103
+ pip install -e . # editable install, creates the `writ` command
104
+ writ --help
105
+ ```
106
+
107
+ ### Publish
108
+
109
+ ```bash
110
+ cd cli
111
+ python -m build
112
+ twine upload dist/*
113
+ ```
@@ -6,8 +6,8 @@ build-backend = "setuptools.build_meta"
6
6
  # Distribution name: pywrit (approved by Chad 2026-09-27).
7
7
  # The bare name "writ" is taken on PyPI.
8
8
  name = "pywrit"
9
- version = "0.1.2"
10
- description = "Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log"
9
+ version = "0.1.4"
10
+ description = "Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
13
13
  keywords = ["agents", "audit-log", "authorization", "api-keys"]
@@ -20,6 +20,15 @@ classifiers = [
20
20
  # No third-party dependencies — the CLI is stdlib-only (argparse, json, urllib).
21
21
  dependencies = []
22
22
 
23
+ [project.optional-dependencies]
24
+ # `pip install 'pywrit[polyglot]'`: lets `writ scan` parse TypeScript and
25
+ # JavaScript (scan only). Python scanning never needs it.
26
+ polyglot = [
27
+ "tree-sitter>=0.23",
28
+ "tree-sitter-typescript>=0.23",
29
+ "tree-sitter-javascript>=0.23",
30
+ ]
31
+
23
32
  [project.urls]
24
33
  Homepage = "https://withwrit.com"
25
34
  Documentation = "https://docs.withwrit.com"
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywrit
3
+ Version: 0.1.4
4
+ Summary: Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log
5
+ Project-URL: Homepage, https://withwrit.com
6
+ Project-URL: Documentation, https://docs.withwrit.com
7
+ Keywords: agents,audit-log,authorization,api-keys
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Security
11
+ Classifier: Topic :: System :: Systems Administration
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: polyglot
16
+ Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
17
+ Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
18
+ Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
19
+ Dynamic: license-file
20
+
21
+ # pywrit
22
+
23
+ The Writ CLI — the gate before the write.
24
+
25
+ ```bash
26
+ pip install pywrit
27
+ writ key --email you@company.com
28
+ ```
29
+
30
+ **Find the consequential writes your agents can perform.** `writ scan .` is a
31
+ free, local scan (no API key) that lists the writes it finds in your agent code. Then gate
32
+ them: Writ is an allow/deny gate you put in front of your agent's writes, and each check
33
+ writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
34
+
35
+ ![writ scan finds 4 writes in a Python agent; writ scan --apply gates all of them](https://withwrit.com/assets/scan-python.gif)
36
+
37
+ ## What it does
38
+
39
+ - `writ key` — Get a free API key (1,000 receipts/month, no card)
40
+ - `writ check` — Ask the gate before a write (ALLOW or DENY)
41
+ - `writ revoke` — Kill switch: revoke a principal's tokens in one click
42
+ - `writ receipts` — See your audit log
43
+ - `writ scan` — Find writes in your repo (Python: scan + instrument; TS/JS: scan only)
44
+
45
+ ## Scanning a repo
46
+
47
+ ```bash
48
+ writ scan ./my-repo # report writes, print the diff, write writ-policy.json
49
+ writ scan ./my-repo --apply # prompt, then insert gates (Python files only)
50
+ pip install 'pywrit[polyglot]' # optional: also scan TypeScript / JavaScript
51
+ ```
52
+
53
+ ![writ scan on a TypeScript support agent: 1 of 5 write sites gated](https://withwrit.com/assets/scan-tsjs.gif)
54
+
55
+ - **Python** (`.py`): parsed with the stdlib `ast`, no extra dependencies.
56
+ Finds DB calls, HTTP clients, file writes, email, queues, and AWS SDK
57
+ writes. `writ scan --apply` inserts a `_writ_check(...)` gate at the top of
58
+ each writing function.
59
+ - **TypeScript / JavaScript** (`.ts .tsx .js .jsx .mjs .cjs`): **scan only**,
60
+ never rewritten, and needs the `polyglot` extra (tree-sitter). Without it,
61
+ `writ scan` prints a one-line install hint and keeps scanning Python.
62
+ Each finding is listed as `file:line [category] rule function(): snippet`,
63
+ and its verb goes into `writ-policy.json` like a Python finding. Detected:
64
+ - HTTP: `fetch` / `axios` / `got` / `ky` / `superagent` with a literal
65
+ `POST`/`PUT`/`PATCH`/`DELETE` method, `axios.post|put|patch|delete`,
66
+ `http(s).request` with a write method
67
+ - Files: `fs.writeFile`, `appendFile`, `unlink`, `rm`, `rename`, `mkdir`, …
68
+ plus `Sync` and `fs/promises` variants
69
+ - SQL: literal `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/… strings passed to
70
+ `query` / `execute` / `raw` (including tagged templates such as
71
+ `prisma.$executeRaw`)
72
+ - SDKs: Stripe `create|update|del` (e.g. `stripe.refunds.create`), Prisma
73
+ `create|update|upsert|delete|deleteMany|updateMany`, knex/db builders
74
+ `insert|update|del`, `child_process` `exec`/`spawn`
75
+ - A function that already calls `writCheck(...)` / `writ_check(...)` counts
76
+ as gated.
77
+ - Skipped: `node_modules`, `dist`, `build`, hidden dirs such as `.git` and
78
+ `.next`, virtualenvs, and test files. TS/JS scanning also skips `vendor`,
79
+ `*.d.ts`, `*.min.js`, and files over 1 MB, and respects `.gitignore` when
80
+ the repo is a git checkout.
81
+ - TS/JS limitations: calls are matched by name, so HTTP methods, SQL strings,
82
+ or options held in variables (`fetch(url, opts)`, `db.query(sql)`) are not
83
+ detected, and route definitions like `router.post(...)` are deliberately
84
+ not flagged. The Writ Score grades Python findings only; the
85
+ `ts/js gated: N/M` line is the TS/JS coverage number.
86
+
87
+ ## Docs
88
+
89
+ Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
90
+
91
+ ---
92
+
93
+ ## Packaging notes (for maintainers)
94
+
95
+ Installable distribution of the Writ CLI. `pip install pywrit` gives you the
96
+ `writ` command — no `curl` + `chmod` dance.
97
+
98
+ ### Layout
99
+
100
+ - `pyproject.toml` — package metadata, `writ` console-script entry point
101
+ - `writ_cli/__init__.py` — version
102
+ - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib;
103
+ TS/JS scanning imports tree-sitter only when the `polyglot` extra is installed)
104
+ - `tests/` — scanner tests: `python -m unittest discover -s tests`
105
+ - Demo GIFs: recorded with [VHS](https://github.com/charmbracelet/vhs) from
106
+ `docs/tapes/*.tape` (repo root: `docs/tapes/render.sh`)
107
+
108
+ ### Relationship to `gate/web/writ`
109
+
110
+ `gate/web/writ` is the **self-contained single-file script** served by the Lambda
111
+ at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
112
+ file with no install step.
113
+
114
+ `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
115
+ byte-identical by copy. If you change CLI behavior, update **both** and keep
116
+ them in sync — the download path and the pip path must behave the same.
117
+
118
+ ### Build & test locally
119
+
120
+ ```bash
121
+ cd cli
122
+ python -m build # needs `pip install build`
123
+ pip install -e . # editable install, creates the `writ` command
124
+ writ --help
125
+ ```
126
+
127
+ ### Publish
128
+
129
+ ```bash
130
+ cd cli
131
+ python -m build
132
+ twine upload dist/*
133
+ ```
@@ -5,6 +5,9 @@ pywrit.egg-info/PKG-INFO
5
5
  pywrit.egg-info/SOURCES.txt
6
6
  pywrit.egg-info/dependency_links.txt
7
7
  pywrit.egg-info/entry_points.txt
8
+ pywrit.egg-info/requires.txt
8
9
  pywrit.egg-info/top_level.txt
10
+ tests/test_scan_tsjs.py
9
11
  writ_cli/__init__.py
10
- writ_cli/cli.py
12
+ writ_cli/cli.py
13
+ writ_cli/risk.py
@@ -0,0 +1,5 @@
1
+
2
+ [polyglot]
3
+ tree-sitter>=0.23
4
+ tree-sitter-typescript>=0.23
5
+ tree-sitter-javascript>=0.23
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic tests for `writ scan` TypeScript/JavaScript support.
3
+
4
+ Fixtures live in tests/fixtures/tsjs. Every line tagged `// expect: <rule>`
5
+ must be reported with that rule, and nothing else may be reported.
6
+ Tests that parse TS/JS skip when the optional `polyglot` extra
7
+ (tree-sitter) is not installed; the rest run with zero extra deps.
8
+ """
9
+
10
+ import argparse
11
+ import contextlib
12
+ import io
13
+ import os
14
+ import pathlib
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import unittest
21
+ from unittest import mock
22
+
23
+ HERE = pathlib.Path(__file__).resolve().parent
24
+ sys.path.insert(0, str(HERE.parent))
25
+
26
+ from writ_cli import cli # noqa: E402
27
+
28
+ FIXTURES = HERE / "fixtures" / "tsjs"
29
+ HAVE_EXTRA = bool(cli._tsjs_parsers())
30
+ _BLOCK_EXTRA = {m: None for m in ("tree_sitter", "tree_sitter_typescript",
31
+ "tree_sitter_javascript",
32
+ "tree_sitter_language_pack")}
33
+ _EXPECT = re.compile(r"//\s*expect:\s*([a-z0-9-]+)")
34
+ _FINDING = re.compile(
35
+ r"^ (?P<file>\S+):(?P<line>\d+) \[(?P<kind>[a-z]+)\] "
36
+ r"(?P<rule>[a-z0-9-]+) (?P<func>\S+)\(\): (?P<snippet>.+)$")
37
+
38
+
39
+ def _args(path, policy_out, **kw):
40
+ ns = argparse.Namespace(path=str(path), exclude=[], policy_out=str(policy_out),
41
+ apply=False, yes=False, score=False, push_policy=False,
42
+ key="")
43
+ for k, v in kw.items():
44
+ setattr(ns, k, v)
45
+ return ns
46
+
47
+
48
+ def _run_scan(path, **kw):
49
+ """Run scan_cmd in-process; return (exit code, stdout)."""
50
+ with tempfile.TemporaryDirectory() as td:
51
+ buf = io.StringIO()
52
+ with contextlib.redirect_stdout(buf):
53
+ code = cli.scan_cmd(_args(path, pathlib.Path(td) / "policy.json", **kw))
54
+ return code, buf.getvalue()
55
+
56
+
57
+ def _expected():
58
+ exp = set()
59
+ for p in sorted(FIXTURES.rglob("*")):
60
+ if p.suffix not in cli._TSJS_EXTS:
61
+ continue
62
+ for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
63
+ m = _EXPECT.search(line)
64
+ if m:
65
+ exp.add((str(p.relative_to(FIXTURES)), i, m.group(1)))
66
+ return exp
67
+
68
+
69
+ def _findings(out):
70
+ return {(m.group("file"), int(m.group("line")), m.group("rule"))
71
+ for m in map(_FINDING.match, out.splitlines()) if m}
72
+
73
+
74
+ def _copy_fixtures(dst):
75
+ shutil.copytree(str(FIXTURES), str(dst))
76
+ return dst
77
+
78
+
79
+ class FixtureRulesTest(unittest.TestCase):
80
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
81
+ def test_every_expected_finding_and_nothing_else(self):
82
+ with tempfile.TemporaryDirectory() as td:
83
+ root = _copy_fixtures(pathlib.Path(td) / "repo") # outside git
84
+ code, out = _run_scan(root)
85
+ self.assertEqual(code, 0)
86
+ self.assertEqual(_findings(out), _expected(), out)
87
+
88
+ def test_every_rule_has_a_fixture(self):
89
+ covered = {r for (_f, _l, r) in _expected()}
90
+ self.assertEqual(covered, {r[0] for r in cli._TSJS_RULES})
91
+
92
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
93
+ def test_negative_reads_not_flagged(self):
94
+ with tempfile.TemporaryDirectory() as td:
95
+ root = pathlib.Path(td)
96
+ shutil.copy(str(FIXTURES / "reads.tsx"), str(root / "reads.tsx"))
97
+ code, out = _run_scan(root)
98
+ self.assertEqual(code, 0)
99
+ self.assertIn("ts/js files scanned: 1 skipped: 0", out)
100
+ self.assertIn("ts/js write sites: 0 in 0 function(s)", out)
101
+ self.assertEqual(_findings(out), set())
102
+ self.assertIn("nothing to instrument.", out)
103
+
104
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
105
+ def test_output_format_counts_gating_and_verbs(self):
106
+ with tempfile.TemporaryDirectory() as td:
107
+ root = _copy_fixtures(pathlib.Path(td) / "repo")
108
+ policy = pathlib.Path(td) / "p.json"
109
+ buf = io.StringIO()
110
+ with contextlib.redirect_stdout(buf):
111
+ code = cli.scan_cmd(_args(root, policy))
112
+ out = buf.getvalue()
113
+ verbs = __import__("json").loads(policy.read_text())
114
+ self.assertEqual(code, 0)
115
+ n = len(_expected())
116
+ self.assertIn("ts/js files scanned: 7 skipped: 0 (scan only, no --apply)", out)
117
+ self.assertIn("ts/js write sites: %d in 11 function(s)" % n, out)
118
+ self.assertIn("ts/js gated: 1/%d" % n, out) # gated.ts calls writCheck
119
+ self.assertIn(" payments/refunds.service.ts:10 [sdk] stripe-write "
120
+ "issueRefund(): stripe.refunds.create({ charge: chargeId })", out)
121
+ self.assertIn(" http.ts:10 [http] fetch-write createOrder(): fetch(url, "
122
+ "{ method: \"POST\", body: JSON.stringify(body) })", out)
123
+ for v in ("payments.refund", "payments.record", "payments.export",
124
+ "storage.save", "storage.create", "http.create", "legacy.write"):
125
+ self.assertEqual(verbs.get(v), "require_grant", v)
126
+ self.assertIn("nothing to instrument (ts/js findings are scan-only", out)
127
+
128
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
129
+ def test_apply_never_rewrites_tsjs_but_still_gates_python(self):
130
+ with tempfile.TemporaryDirectory() as td:
131
+ root = pathlib.Path(td) / "repo"
132
+ (root / "orders").mkdir(parents=True)
133
+ ts = root / "orders" / "api.ts"
134
+ ts.write_text('export async function createOrder(u: string) {\n'
135
+ ' await fetch(u, { method: "POST" });\n}\n')
136
+ py = root / "orders" / "store.py"
137
+ py.write_text('def create_order():\n'
138
+ ' with open("/tmp/o", "w") as f:\n'
139
+ ' f.write("o")\n')
140
+ before = ts.read_bytes()
141
+ code, out = _run_scan(root, apply=True, yes=True)
142
+ self.assertEqual(code, 0)
143
+ self.assertEqual(ts.read_bytes(), before)
144
+ self.assertIn("_writ_check(\"orders.create\")", py.read_text())
145
+ self.assertIn("orders/api.ts:2 [http] fetch-write createOrder()", out)
146
+ self.assertIn("applied 1 gate(s) to 1 file(s).", out)
147
+
148
+
149
+ class FileWalkTest(unittest.TestCase):
150
+ """File collection is stdlib-only, so these run with or without the extra."""
151
+
152
+ WRITE = 'export function f(){ return fetch("/x", { method: "POST" }); }\n'
153
+
154
+ def _tree(self, root, rels):
155
+ for rel in rels:
156
+ p = root / rel
157
+ p.parent.mkdir(parents=True, exist_ok=True)
158
+ p.write_text(self.WRITE)
159
+
160
+ def test_skip_dirs_and_test_files(self):
161
+ with tempfile.TemporaryDirectory() as td:
162
+ root = pathlib.Path(td)
163
+ skipped = ["node_modules/pkg/index.js", "dist/app.js", "build/app.js",
164
+ ".next/server/page.js", ".git/hooks/x.js",
165
+ "vendor/lib.js", "venv/lib/x.js", ".venv/x.js",
166
+ "src/__tests__/a.ts", "tests/a.ts", "src/a.test.ts",
167
+ "src/a.spec.tsx", "types/a.d.ts", "public/app.min.js",
168
+ "src/readme.md", "src/a.py"]
169
+ kept = ["src/a.ts", "src/b.tsx", "src/c.js", "src/d.jsx",
170
+ "src/e.mjs", "src/f.cjs", "lib/distance.ts"]
171
+ self._tree(root, skipped + kept)
172
+ got = [str(p.relative_to(root))
173
+ for p in cli._tsjs_collect_files(root, [])]
174
+ self.assertEqual(sorted(got), sorted(kept))
175
+
176
+ def test_exclude_substring(self):
177
+ with tempfile.TemporaryDirectory() as td:
178
+ root = pathlib.Path(td)
179
+ self._tree(root, ["src/a.ts", "generated/b.ts"])
180
+ got = [str(p.relative_to(root))
181
+ for p in cli._tsjs_collect_files(root, ["generated"])]
182
+ self.assertEqual(got, ["src/a.ts"])
183
+
184
+ @unittest.skipUnless(shutil.which("git"), "git not installed")
185
+ def test_respects_gitignore_inside_git_repo(self):
186
+ with tempfile.TemporaryDirectory() as td:
187
+ root = pathlib.Path(td)
188
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
189
+ (root / ".gitignore").write_text("generated/\n*.gen.ts\n")
190
+ self._tree(root, ["src/a.ts", "generated/b.ts", "src/c.gen.ts",
191
+ "node_modules/x/y.js"])
192
+ got = [str(p.relative_to(root))
193
+ for p in cli._tsjs_collect_files(root, [])]
194
+ self.assertEqual(got, ["src/a.ts"])
195
+
196
+
197
+ class MissingExtraTest(unittest.TestCase):
198
+ def test_hint_once_and_python_still_scanned(self):
199
+ with tempfile.TemporaryDirectory() as td:
200
+ root = pathlib.Path(td)
201
+ (root / "web").mkdir()
202
+ (root / "web" / "a.ts").write_text(FileWalkTest.WRITE)
203
+ (root / "web" / "b.js").write_text(FileWalkTest.WRITE)
204
+ (root / "orders.py").write_text(
205
+ 'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
206
+ with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
207
+ self.assertEqual(cli._tsjs_parsers(), {})
208
+ code, out = _run_scan(root)
209
+ self.assertEqual(code, 0)
210
+ hints = [l for l in out.splitlines() if "hint:" in l]
211
+ self.assertEqual(hints, [" hint: 2 TS/JS file(s) not scanned; install "
212
+ "the extra: pip install 'pywrit[polyglot]'"])
213
+ self.assertIn("write sites: 1 in 1 function(s)", out)
214
+ self.assertIn("orders.create", out)
215
+ self.assertNotIn("ts/js files scanned", out)
216
+
217
+ def test_python_only_repo_output_unchanged(self):
218
+ with tempfile.TemporaryDirectory() as td:
219
+ root = pathlib.Path(td)
220
+ (root / "orders.py").write_text(
221
+ 'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
222
+ with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
223
+ code, out = _run_scan(root)
224
+ self.assertEqual(code, 0)
225
+ self.assertNotIn("hint:", out)
226
+ self.assertNotIn("ts/js", out)
227
+ self.assertTrue(out.startswith("writ scan: %s\n files scanned: 1 skipped: 0\n"
228
+ " write sites: 1 in 1 function(s)\n"
229
+ " gated: 0/1 (0%%)\n verbs discovered: 1\n"
230
+ " orders.create\n" % root.resolve()), out)
231
+
232
+
233
+ class CliSyncTest(unittest.TestCase):
234
+ def test_download_copy_is_byte_identical(self):
235
+ web = HERE.parent.parent / "gate" / "web" / "writ"
236
+ if not web.exists():
237
+ self.skipTest("gate/web/writ not present")
238
+ self.assertEqual(web.read_bytes(),
239
+ (HERE.parent / "writ_cli" / "cli.py").read_bytes())
240
+
241
+
242
+ if __name__ == "__main__":
243
+ unittest.main(verbosity=2)