@miller-tech/uap 1.210.7 → 1.211.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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +7 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/schema-diff.d.ts +118 -2
- package/dist/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +692 -41
- package/dist/cli/schema-diff.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +7 -4
- package/dist/cli/verify.js.map +1 -1
- package/package.json +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +629 -41
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_schema_diff_gate.py +292 -7
- package/tools/agents/tests/test_schema_diff_inline.py +373 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Tests for the inline-check pivot in the schema-diff gate.
|
|
2
|
+
|
|
3
|
+
The gate used to ask "is there a marker saying somebody ran the checker?" and
|
|
4
|
+
spent three review rounds trying to bind that marker to the bytes being
|
|
5
|
+
committed. It now runs the checker itself, on the exact bytes the command will
|
|
6
|
+
store.
|
|
7
|
+
|
|
8
|
+
Nearly every test here exists because an adversarial review got past an earlier
|
|
9
|
+
version of this code and the transcript is in the docstring. The through-line:
|
|
10
|
+
the gate must believe the checker ONLY when the checker demonstrably read the
|
|
11
|
+
right bytes and understood them. "No breaking changes" from something that
|
|
12
|
+
could not open the file, or had no analyser for it, or answered about a
|
|
13
|
+
different blob, is not an all-clear -- and every one of those was, at some
|
|
14
|
+
point, reported to the operator as one.
|
|
15
|
+
|
|
16
|
+
The checker is stubbed at the path the gate invokes, so these also pin the
|
|
17
|
+
invocation contract: --paths-from, --source, and the verdict shape.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
import unittest
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
31
|
+
ENFORCER = REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py"
|
|
32
|
+
|
|
33
|
+
WATCHED = "migrations/001_add_table.sql"
|
|
34
|
+
BASE_SQL = "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);\n"
|
|
35
|
+
BREAKING_SQL = "CREATE TABLE t (id INTEGER PRIMARY KEY);\n"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_gate(op: str, args: dict, root: Path, env_extra: dict | None = None):
|
|
39
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
40
|
+
env["UAP_REPO_ROOT"] = str(root)
|
|
41
|
+
env["UAP_WORKTREE_ROOT"] = str(root)
|
|
42
|
+
env.update(env_extra or {})
|
|
43
|
+
r = subprocess.run(
|
|
44
|
+
["python3", str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
|
|
45
|
+
cwd=root,
|
|
46
|
+
env=env,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=120,
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(r.stdout or "{}")
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
payload = {}
|
|
55
|
+
return payload.get("allowed", False), payload.get("reason", "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@unittest.skipIf(shutil.which("node") is None, "node is required to stub the checker")
|
|
59
|
+
class InlineSchemaDiffTest(unittest.TestCase):
|
|
60
|
+
def setUp(self):
|
|
61
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="schema-inline-")
|
|
62
|
+
self.root = Path(self._tmp.name)
|
|
63
|
+
git = ["git", "-c", "user.email=t@t", "-c", "user.name=t"]
|
|
64
|
+
subprocess.run(["git", "init", "-q"], cwd=self.root, check=True)
|
|
65
|
+
# The gate only prefers a tree-local dist/ when the tree IS the UAP
|
|
66
|
+
# checkout; see test_a_local_build_in_someone_elses_repo_is_not_run.
|
|
67
|
+
(self.root / "package.json").write_text('{"name":"@miller-tech/uap"}\n')
|
|
68
|
+
(self.root / "migrations").mkdir()
|
|
69
|
+
(self.root / WATCHED).write_text(BASE_SQL)
|
|
70
|
+
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
|
|
71
|
+
subprocess.run(git + ["commit", "-q", "-m", "base"], cwd=self.root, check=True)
|
|
72
|
+
(self.root / WATCHED).write_text(BREAKING_SQL)
|
|
73
|
+
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
|
|
74
|
+
|
|
75
|
+
def tearDown(self):
|
|
76
|
+
self._tmp.cleanup()
|
|
77
|
+
|
|
78
|
+
# -- helpers ------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def staged_sha(self, path: str = WATCHED) -> str:
|
|
81
|
+
return subprocess.run(
|
|
82
|
+
["git", "rev-parse", f":{path}"],
|
|
83
|
+
cwd=self.root, capture_output=True, text=True, check=True,
|
|
84
|
+
).stdout.strip()
|
|
85
|
+
|
|
86
|
+
def stub_checker(self, payload, *, log_argv: bool = False):
|
|
87
|
+
"""Install a fake checker where the gate looks for it.
|
|
88
|
+
|
|
89
|
+
`payload` is emitted verbatim as the last stdout line: a dict for a
|
|
90
|
+
well-formed verdict, a string to test how the gate handles noise.
|
|
91
|
+
"""
|
|
92
|
+
bin_dir = self.root / "dist" / "bin"
|
|
93
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
body = payload if isinstance(payload, str) else json.dumps(payload)
|
|
95
|
+
# Logged relative to cwd, not via an env var: the gate allowlists the
|
|
96
|
+
# child's environment (it must not hand an agent's credentials to a
|
|
97
|
+
# subprocess chosen partly by filesystem contents), so ARGV_LOG would
|
|
98
|
+
# never arrive.
|
|
99
|
+
prelude = (
|
|
100
|
+
"require('fs').appendFileSync('argv.log', "
|
|
101
|
+
"JSON.stringify(process.argv.slice(2)) + '\\n');"
|
|
102
|
+
if log_argv
|
|
103
|
+
else ""
|
|
104
|
+
)
|
|
105
|
+
(bin_dir / "cli.js").write_text(f"{prelude}\nconsole.log({json.dumps(body)});\n")
|
|
106
|
+
|
|
107
|
+
def verdict(self, *, breaking=(), sha=None, analysed=True, contract=1, path=WATCHED):
|
|
108
|
+
return {
|
|
109
|
+
"contract": contract,
|
|
110
|
+
"ran": True,
|
|
111
|
+
"base": "HEAD",
|
|
112
|
+
"files": [
|
|
113
|
+
{
|
|
114
|
+
"path": path,
|
|
115
|
+
"sha": self.staged_sha() if sha is None else sha,
|
|
116
|
+
"analysed": analysed,
|
|
117
|
+
"breaking": list(breaking),
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
BREAK = ['Field "t.name" (TEXT) was removed']
|
|
123
|
+
|
|
124
|
+
def commit(self, command="git commit -m x", **env):
|
|
125
|
+
return run_gate("Bash", {"command": command}, self.root, env_extra=env or None)
|
|
126
|
+
|
|
127
|
+
# -- the pivot ----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def test_a_clean_inline_run_clears_the_gate_with_no_marker_at_all(self):
|
|
130
|
+
"""The point of the change: evidence is produced, not looked up."""
|
|
131
|
+
self.stub_checker(self.verdict())
|
|
132
|
+
allowed, reason = self.commit()
|
|
133
|
+
self.assertTrue(allowed, reason)
|
|
134
|
+
self.assertIn("no breaking changes", reason)
|
|
135
|
+
|
|
136
|
+
def test_a_breaking_change_is_refused_and_named(self):
|
|
137
|
+
self.stub_checker(self.verdict(breaking=self.BREAK))
|
|
138
|
+
allowed, reason = self.commit()
|
|
139
|
+
self.assertFalse(allowed, "a breaking schema change must not commit")
|
|
140
|
+
self.assertIn("BREAKING", reason)
|
|
141
|
+
self.assertIn("t.name", reason, "the refusal must say what broke")
|
|
142
|
+
|
|
143
|
+
# -- believing the checker ----------------------------------------------
|
|
144
|
+
|
|
145
|
+
def test_a_verdict_about_different_bytes_is_not_an_answer(self):
|
|
146
|
+
"""The sha is re-derived, not taken on trust.
|
|
147
|
+
|
|
148
|
+
The first version compared the paths it sent against the paths echoed
|
|
149
|
+
back -- two copies of its own argument. That can fail on a whitespace
|
|
150
|
+
artefact and on nothing else, so it could not detect the case it was
|
|
151
|
+
written for: a checker reporting clean for bytes it never read.
|
|
152
|
+
"""
|
|
153
|
+
self.stub_checker(self.verdict(sha="0" * 40))
|
|
154
|
+
allowed, reason = self.commit()
|
|
155
|
+
self.assertFalse(allowed, f"sha mismatch must not clear the gate: {reason}")
|
|
156
|
+
self.assertIn("uap schema-diff", reason, "must fall back to the shipped remedy")
|
|
157
|
+
|
|
158
|
+
def test_a_file_no_analyser_understood_is_not_clean(self):
|
|
159
|
+
"""`analysed: false` is the helm/spock case, and it is the big one.
|
|
160
|
+
|
|
161
|
+
WATCHED_RE covers infra/helm_charts/** and infra/postgres-spock/**,
|
|
162
|
+
which are YAML; the differ has branches for .ts/.js, .sql and .json
|
|
163
|
+
only. Those paths produced an empty change list, and the gate published
|
|
164
|
+
it as "schema-diff ran ... no breaking changes" -- an affirmative
|
|
165
|
+
safety claim about a file nothing could parse, replacing master's
|
|
166
|
+
requirement that a human run the checker first.
|
|
167
|
+
"""
|
|
168
|
+
self.stub_checker(self.verdict(analysed=False))
|
|
169
|
+
allowed, reason = self.commit()
|
|
170
|
+
self.assertFalse(allowed, f"unanalysed must not read as clean: {reason}")
|
|
171
|
+
|
|
172
|
+
def test_a_verdict_in_an_unknown_contract_is_not_an_answer(self):
|
|
173
|
+
self.stub_checker(self.verdict(contract=99))
|
|
174
|
+
allowed, _ = self.commit()
|
|
175
|
+
self.assertFalse(allowed, "an unrecognised verdict shape must not clear the gate")
|
|
176
|
+
|
|
177
|
+
def test_a_missing_entry_is_not_an_answer(self):
|
|
178
|
+
"""Silence about a watched path is not a clean bill of health."""
|
|
179
|
+
self.stub_checker({"contract": 1, "ran": True, "base": "HEAD", "files": []})
|
|
180
|
+
allowed, _ = self.commit()
|
|
181
|
+
self.assertFalse(allowed)
|
|
182
|
+
|
|
183
|
+
def test_ran_false_is_not_an_answer(self):
|
|
184
|
+
self.stub_checker({"contract": 1, "ran": False, "base": "HEAD", "files": []})
|
|
185
|
+
allowed, _ = self.commit()
|
|
186
|
+
self.assertFalse(allowed)
|
|
187
|
+
|
|
188
|
+
def test_noise_on_stdout_is_not_an_answer(self):
|
|
189
|
+
self.stub_checker("not json at all")
|
|
190
|
+
allowed, _ = self.commit()
|
|
191
|
+
self.assertFalse(allowed)
|
|
192
|
+
|
|
193
|
+
def test_a_malformed_verdict_cannot_crash_the_gate_open(self):
|
|
194
|
+
"""A crash IS an allow.
|
|
195
|
+
|
|
196
|
+
.claude/hooks/uap-policy-gate.sh maps a non-zero exit or unparseable
|
|
197
|
+
output to allowed for every enforcer except self-protect. Two shapes
|
|
198
|
+
reachable from a future CLI raised TypeError out of main(): a
|
|
199
|
+
`breaking` that is a bool rather than a list, and `files` entries that
|
|
200
|
+
are objects where strings were expected.
|
|
201
|
+
"""
|
|
202
|
+
for payload in (
|
|
203
|
+
{"contract": 1, "ran": True, "files": [{"path": WATCHED, "sha": "x", "analysed": True, "breaking": True}]},
|
|
204
|
+
{"contract": 1, "ran": True, "files": [{"path": WATCHED, "sha": "x", "analysed": True, "breaking": {"a": 1}}]},
|
|
205
|
+
{"contract": 1, "ran": True, "files": ["not-an-object"]},
|
|
206
|
+
{"contract": 1, "ran": True, "files": {"not": "a list"}},
|
|
207
|
+
):
|
|
208
|
+
with self.subTest(payload=payload):
|
|
209
|
+
self.stub_checker(payload)
|
|
210
|
+
allowed, reason = self.commit()
|
|
211
|
+
self.assertFalse(allowed, f"malformed verdict cleared the gate: {reason}")
|
|
212
|
+
self.assertNotEqual(reason, "", "the gate must answer, not die silently")
|
|
213
|
+
|
|
214
|
+
def test_a_missing_checker_leaves_the_shipped_behaviour_untouched(self):
|
|
215
|
+
allowed, reason = self.commit()
|
|
216
|
+
self.assertFalse(allowed, f"missing checker must not open the gate: {reason}")
|
|
217
|
+
self.assertIn("uap schema-diff", reason)
|
|
218
|
+
|
|
219
|
+
# -- what gets examined --------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def test_the_gate_names_the_paths_and_the_source(self):
|
|
222
|
+
log = self.root / "argv.log"
|
|
223
|
+
self.stub_checker(self.verdict(), log_argv=True)
|
|
224
|
+
self.commit()
|
|
225
|
+
argv = json.loads(log.read_text().splitlines()[0])
|
|
226
|
+
self.assertIn("--json", argv)
|
|
227
|
+
self.assertIn("--source", argv)
|
|
228
|
+
self.assertEqual(argv[argv.index("--source") + 1], "index")
|
|
229
|
+
# Paths travel in a NUL-separated file, never on the command line: a
|
|
230
|
+
# comma split one path into two, and git C-quotes newlines and unicode
|
|
231
|
+
# into strings that name no file at all -- which the checker then
|
|
232
|
+
# reported clean.
|
|
233
|
+
self.assertIn("--paths-from", argv)
|
|
234
|
+
self.assertEqual(
|
|
235
|
+
Path(argv[argv.index("--paths-from") + 1]).name.startswith("uap-schema-paths-"),
|
|
236
|
+
True,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def test_a_diverging_worktree_is_checked_as_well_as_the_index(self):
|
|
240
|
+
"""Both candidate byte-strings, because the command form cannot be read.
|
|
241
|
+
|
|
242
|
+
`git commit -- <path>`, `--only`, `--include`, `-o` and `-i` all store
|
|
243
|
+
the WORKTREE copy and contain no `-a`; meanwhile the `-A` in
|
|
244
|
+
`git add -A && git commit` matched the old short-flag test and pushed
|
|
245
|
+
the gate to the worktree for a commit that stores the index. Both
|
|
246
|
+
directions were demonstrated bypasses, so the inference is gone.
|
|
247
|
+
"""
|
|
248
|
+
(self.root / WATCHED).write_text(BASE_SQL + "-- diverged\n") # unstaged
|
|
249
|
+
log = self.root / "argv.log"
|
|
250
|
+
self.stub_checker(self.verdict(), log_argv=True)
|
|
251
|
+
self.commit()
|
|
252
|
+
sources = [
|
|
253
|
+
json.loads(line)[json.loads(line).index("--source") + 1]
|
|
254
|
+
for line in log.read_text().splitlines()
|
|
255
|
+
if line.strip()
|
|
256
|
+
]
|
|
257
|
+
self.assertEqual(sorted(sources), ["index", "worktree"])
|
|
258
|
+
|
|
259
|
+
def test_only_one_check_when_index_and_worktree_agree(self):
|
|
260
|
+
"""The common case must not pay for the rare one."""
|
|
261
|
+
log = self.root / "argv.log"
|
|
262
|
+
self.stub_checker(self.verdict(), log_argv=True)
|
|
263
|
+
self.commit()
|
|
264
|
+
self.assertEqual(len([x for x in log.read_text().splitlines() if x.strip()]), 1)
|
|
265
|
+
|
|
266
|
+
# -- the escape hatch ----------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _write_waiver(self, body: str, *, commit: bool) -> None:
|
|
269
|
+
waivers = self.root / "policies" / "waivers"
|
|
270
|
+
waivers.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
(waivers / "deliberate-schema-diff.md").write_text(body)
|
|
272
|
+
if commit:
|
|
273
|
+
subprocess.run(["git", "add", "policies"], cwd=self.root, check=True)
|
|
274
|
+
subprocess.run(
|
|
275
|
+
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q",
|
|
276
|
+
"-m", "waiver", "--only", "policies"],
|
|
277
|
+
cwd=self.root, check=True,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def test_a_committed_waiver_naming_the_path_clears_a_breaking_change(self):
|
|
281
|
+
self.stub_checker(self.verdict(breaking=self.BREAK))
|
|
282
|
+
self._write_waiver(f"Deliberate: dropping name from {WATCHED}\n", commit=True)
|
|
283
|
+
allowed, reason = self.commit()
|
|
284
|
+
self.assertTrue(allowed, reason)
|
|
285
|
+
self.assertIn("waived", reason)
|
|
286
|
+
|
|
287
|
+
def test_an_uncommitted_waiver_does_not(self):
|
|
288
|
+
"""`touch policies/waivers/x-schema-diff.md` was a total off-switch.
|
|
289
|
+
|
|
290
|
+
The check was Path.is_file() on the working tree, and
|
|
291
|
+
enforcement_self_protect deliberately exempts policies/waivers/ so that
|
|
292
|
+
agents CAN write there -- a carve-out justified when a waiver only
|
|
293
|
+
satisfied expert-review. An empty, never-committed file cleared every
|
|
294
|
+
breaking change. Verified.
|
|
295
|
+
"""
|
|
296
|
+
self.stub_checker(self.verdict(breaking=self.BREAK))
|
|
297
|
+
self._write_waiver("", commit=False)
|
|
298
|
+
allowed, reason = self.commit()
|
|
299
|
+
self.assertFalse(allowed, f"an uncommitted waiver must not clear it: {reason}")
|
|
300
|
+
|
|
301
|
+
def test_a_waiver_for_a_different_path_does_not(self):
|
|
302
|
+
self.stub_checker(self.verdict(breaking=self.BREAK))
|
|
303
|
+
self._write_waiver("Deliberate: migrations/999_unrelated.sql\n", commit=True)
|
|
304
|
+
allowed, reason = self.commit()
|
|
305
|
+
self.assertFalse(allowed, f"a waiver must name what it excuses: {reason}")
|
|
306
|
+
|
|
307
|
+
# -- the gate's own subprocess ------------------------------------------
|
|
308
|
+
|
|
309
|
+
def test_the_inline_guard_stands_down_but_does_not_allow(self):
|
|
310
|
+
"""The 2026-07-08 self-deadlock mechanism, without a new off-switch.
|
|
311
|
+
|
|
312
|
+
The guard has to exist so a nested gate does not call the checker
|
|
313
|
+
recursively. It must not ALLOW, though: the variable travels with the
|
|
314
|
+
shell, and the first version emitted allowed:true before looking at
|
|
315
|
+
anything, which made `UAP_SCHEMA_DIFF_INLINE=1` a complete disable for
|
|
316
|
+
a security control -- and unlike every comparable switch it was not in
|
|
317
|
+
self-protect's BYPASS_PATTERNS. Skipping to the fallback costs a forger
|
|
318
|
+
exactly what they already had.
|
|
319
|
+
"""
|
|
320
|
+
self.stub_checker(self.verdict(breaking=self.BREAK))
|
|
321
|
+
allowed, reason = self.commit(UAP_SCHEMA_DIFF_INLINE="1")
|
|
322
|
+
self.assertFalse(allowed, f"the guard must not be an off-switch: {reason}")
|
|
323
|
+
self.assertIn("uap schema-diff", reason, "it should land in the fallback")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@unittest.skipIf(shutil.which("node") is None, "node is required to stub the checker")
|
|
327
|
+
class CheckerProvenanceTest(unittest.TestCase):
|
|
328
|
+
"""Where the verdict comes from is part of the trust boundary."""
|
|
329
|
+
|
|
330
|
+
def setUp(self):
|
|
331
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="schema-prov-")
|
|
332
|
+
self.root = Path(self._tmp.name)
|
|
333
|
+
subprocess.run(["git", "init", "-q"], cwd=self.root, check=True)
|
|
334
|
+
(self.root / "migrations").mkdir()
|
|
335
|
+
(self.root / WATCHED).write_text(BASE_SQL)
|
|
336
|
+
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
|
|
337
|
+
subprocess.run(
|
|
338
|
+
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "base"],
|
|
339
|
+
cwd=self.root, check=True,
|
|
340
|
+
)
|
|
341
|
+
(self.root / WATCHED).write_text(BREAKING_SQL)
|
|
342
|
+
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
|
|
343
|
+
bin_dir = self.root / "dist" / "bin"
|
|
344
|
+
bin_dir.mkdir(parents=True)
|
|
345
|
+
# A rubber stamp: three lines, and it attests to anything.
|
|
346
|
+
(bin_dir / "cli.js").write_text(
|
|
347
|
+
"console.log(JSON.stringify({contract:1,ran:true,base:'HEAD',files:"
|
|
348
|
+
"[{path:'" + WATCHED + "',sha:'x',analysed:true,breaking:[]}]}));\n"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def tearDown(self):
|
|
352
|
+
self._tmp.cleanup()
|
|
353
|
+
|
|
354
|
+
def test_a_local_build_in_someone_elses_repo_is_not_run(self):
|
|
355
|
+
"""dist/bin/cli.js is gitignored build output in any Node project.
|
|
356
|
+
|
|
357
|
+
Preferring it handed the verdict to a file inside the tree being gated
|
|
358
|
+
-- an easier forgery than the marker row this design replaced, and in a
|
|
359
|
+
consumer repo it means committing a migration executes that project's
|
|
360
|
+
unrelated build output. It is only trusted when the tree IS the UAP
|
|
361
|
+
checkout, where it is the same artefact `npm i -g .` installs.
|
|
362
|
+
"""
|
|
363
|
+
(self.root / "package.json").write_text('{"name":"totally-normal-app"}\n')
|
|
364
|
+
allowed, reason = run_gate("Bash", {"command": "git commit -m x"}, self.root)
|
|
365
|
+
self.assertFalse(allowed, f"a foreign dist/ must not be the oracle: {reason}")
|
|
366
|
+
|
|
367
|
+
def test_no_package_json_is_also_not_the_uap_checkout(self):
|
|
368
|
+
allowed, reason = run_gate("Bash", {"command": "git commit -m x"}, self.root)
|
|
369
|
+
self.assertFalse(allowed, f"unidentified tree must not be trusted: {reason}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
unittest.main()
|