border-customs 0.1.0 → 0.3.1
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 +256 -40
- package/dist/assets/residue-src/crates.ts +228 -0
- package/dist/assets/residue-src/npm.ts +294 -0
- package/dist/assets/residue-src/pypi.ts +333 -0
- package/dist/assets/residue-src/residue.ts +348 -0
- package/dist/assets/residue-src/residueGem.ts +371 -0
- package/dist/assets/residue-src/residueMatchers.ts +417 -0
- package/dist/assets/residue-src/residuePy.ts +26 -0
- package/dist/assets/residue-src/residueRust.ts +252 -0
- package/dist/assets/residue-src/rubygems.ts +278 -0
- package/dist/index.js +5126 -2825
- package/package.json +6 -2
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// provenance: RESIDUE-CONTRACT.md §1.6/§4/§5.3 + adjudications #1-#5 (R2, border-residue-gate).
|
|
2
|
+
// Classifier ONLY — every pattern/rule-id table lives in src/rules/residueMatchers.ts (grep AC 5).
|
|
3
|
+
// FAIL-CLOSED contract: any unreadable file, missing hook target, multi-hop/odd grammar, or a
|
|
4
|
+
// single out-of-signature literal returns signatureId=null and the CALLER keeps today's verbatim
|
|
5
|
+
// lifecycle-script CRITICAL. T1 requires ALL §1.6 clauses; it never widens (plan Must-NOT).
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { Finding, Severity } from "../findings.ts";
|
|
10
|
+
import { redact, type TextSanitizer } from "../redact.ts";
|
|
11
|
+
import { NPM_TARGET_LABEL, sanitizeOut } from "./npmPack.ts";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
FAMILY_PATTERNS,
|
|
15
|
+
MARKER_BEGIN,
|
|
16
|
+
MARKER_END,
|
|
17
|
+
MARKER_INVERSE_SURFACE,
|
|
18
|
+
MARKER_REMOVE_SHAPE,
|
|
19
|
+
MARKER_WRITE_SHAPE,
|
|
20
|
+
RESIDUE_PAIRING_RULE,
|
|
21
|
+
RESIDUE_SEVERITIES,
|
|
22
|
+
RESIDUE_T1_SIGNATURE_ID,
|
|
23
|
+
RESIDUE_T2_RULE,
|
|
24
|
+
RESIDUE_T3_RULE,
|
|
25
|
+
RESIDUE_T4_RULE,
|
|
26
|
+
RESIDUE_XMGR_RULE,
|
|
27
|
+
RESOLVABLE_HOOK,
|
|
28
|
+
T1_ABS_PATH_LITERAL,
|
|
29
|
+
T1_ALLOWED_ABS_PATHS,
|
|
30
|
+
T1_ALLOWED_MODULES,
|
|
31
|
+
T1_DYNAMIC_REF,
|
|
32
|
+
T1_FORBIDDEN_USER_PATH_MARKS,
|
|
33
|
+
T1_HOOK_GRAMMAR,
|
|
34
|
+
T1_LINE_CAP,
|
|
35
|
+
T1_METAPROGRAMMING,
|
|
36
|
+
T1_MODULE_REF,
|
|
37
|
+
T1_REQUIRED_LITERALS,
|
|
38
|
+
T1_SPAWN_ARGV0_IDENTIFIERS,
|
|
39
|
+
T1_SPAWN_ARGV0_LITERALS,
|
|
40
|
+
T1_SPAWN_CALL,
|
|
41
|
+
T1_SPAWN_NAME,
|
|
42
|
+
T1_WRITE_ARGV0_FORMS,
|
|
43
|
+
T1_WRITE_CALL,
|
|
44
|
+
T1_WRITE_NAME,
|
|
45
|
+
XMGR_LABELS,
|
|
46
|
+
isPlainHookString,
|
|
47
|
+
verbsAllCalled,
|
|
48
|
+
} from "../rules/residueMatchers.ts";
|
|
49
|
+
/** Artifact-target Findings for residue rows — same native shape as npm.ts's lifecycle rows.
|
|
50
|
+
* `sep` attributes rows to their archive: npm `package/x`, pypi `<archive>!<inner>` (plan R3a AC). */
|
|
51
|
+
export function residueFindings(hits: readonly ResidueHit[], o: { root: string; identity: string; sanitizer?: TextSanitizer; sep?: string }): Finding[] {
|
|
52
|
+
return hits.map((hit) => {
|
|
53
|
+
// VERIFIER-REPORT-R3A N2: a lane-specific patternId separates digests when two independent
|
|
54
|
+
// detectors emit the same (rule, file) pair — otherwise one allow-list entry mutes both rows.
|
|
55
|
+
// Absent patternId keeps the pre-R3a key byte-identical, so every npm 0.2.0-pinned digest holds.
|
|
56
|
+
const digest = redact(`${o.identity}:residue:${hit.rule}:${hit.fileRel}${hit.patternId === undefined ? "" : `:${hit.patternId}`}`);
|
|
57
|
+
return {
|
|
58
|
+
rule: hit.rule,
|
|
59
|
+
severity: hit.severity,
|
|
60
|
+
target: NPM_TARGET_LABEL,
|
|
61
|
+
path: o.root === "" ? hit.fileRel : `${o.root}${o.sep ?? "/"}${hit.fileRel}`,
|
|
62
|
+
engine: "native",
|
|
63
|
+
message: sanitizeOut(o.sanitizer, hit.message),
|
|
64
|
+
valueDigest: digest.valueDigest,
|
|
65
|
+
snippet: digest.snippet,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ResidueHit = {
|
|
71
|
+
readonly rule: string;
|
|
72
|
+
readonly severity: Severity;
|
|
73
|
+
readonly message: string;
|
|
74
|
+
/** path relative to the extracted package root (`package/`); residueFindings scopes + attributes. */
|
|
75
|
+
readonly fileRel: string;
|
|
76
|
+
/** digest discriminator for lanes that can double up on one (rule, file) pair (R3A-N2);
|
|
77
|
+
* undefined = the 0.2.0 key shape, NEVER set on rows whose digests are golden-pinned. */
|
|
78
|
+
readonly patternId?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type HookScan = {
|
|
82
|
+
/** non-null ONLY when every §1.6 clause holds; names the matched signature id. */
|
|
83
|
+
readonly signatureId: string | null;
|
|
84
|
+
/** T2/T3/cross-manager rows for the resolved hook file (T4/marker checks come from the sweep). */
|
|
85
|
+
readonly hits: readonly ResidueHit[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const SCAN_LINE_CAP = 100;
|
|
89
|
+
const SWEEP_EXTS = new Set(["bash", "bat", "cjs", "cmd", "js", "json", "lua", "mjs", "pl", "ps1", "py", "rb", "rs", "sh", "zsh"]);
|
|
90
|
+
const SWEEP_MAX_BYTES = 512 * 1024;
|
|
91
|
+
|
|
92
|
+
/** rel paths are package-root-relative; extract-list entries arrive as `package/<rel>`,
|
|
93
|
+
* manifest bin values as `./<rel>` — both normalize to `<rel>`. */
|
|
94
|
+
function toPkgRel(rel: string): string {
|
|
95
|
+
const bare = rel.startsWith("package/") ? rel.slice("package/".length) : rel;
|
|
96
|
+
return bare.replace(/^\.\//, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function readText(pkgDir: string, rel: string): string | null {
|
|
100
|
+
try {
|
|
101
|
+
const abs = join(pkgDir, rel);
|
|
102
|
+
if (!abs.startsWith(pkgDir) || !existsSync(abs)) return null;
|
|
103
|
+
const buf = readFileSync(abs);
|
|
104
|
+
if (buf.byteLength > SWEEP_MAX_BYTES) return null;
|
|
105
|
+
if (buf.subarray(0, 8192).includes(0)) return null; // binary
|
|
106
|
+
return buf.toString("utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function evidenceLine(text: string, pattern: RegExp): string {
|
|
113
|
+
for (const line of text.split("\n")) {
|
|
114
|
+
if (pattern.test(line)) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
return trimmed.length > SCAN_LINE_CAP ? `${trimmed.slice(0, SCAN_LINE_CAP)}…` : trimmed;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Ecosystem wording for the cross-manager row (`<what> code … outside the <ledger> ledger`).
|
|
123
|
+
* npm keeps the 0.2.0 byte-identical strings; R3a legs name their own build phase/ledger. */
|
|
124
|
+
export type FamilyWording = { readonly what: string; readonly ledger: string };
|
|
125
|
+
export const FAMILY_WORDING: Readonly<Record<"npm" | "pypi" | "crates", FamilyWording>> = {
|
|
126
|
+
npm: { what: "lifecycle", ledger: "npm" },
|
|
127
|
+
pypi: { what: "build-hook", ledger: "pip" },
|
|
128
|
+
crates: { what: "build-script", ledger: "cargo" },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Per-file T2/T3/cross-manager scan (T4 + marker pair-detection are artifact-wide — see sweep). */
|
|
132
|
+
export function familyHits(fileRel: string, text: string, w: FamilyWording = FAMILY_WORDING.npm): ResidueHit[] {
|
|
133
|
+
const byRule = new Map<string, { first: string; labels: string[] }>();
|
|
134
|
+
for (const fam of FAMILY_PATTERNS) {
|
|
135
|
+
if (fam.rule === RESIDUE_T4_RULE || fam.rule === RESIDUE_PAIRING_RULE) continue;
|
|
136
|
+
if (!fam.pattern.test(text)) continue;
|
|
137
|
+
const bucket = byRule.get(fam.rule) ?? { first: "", labels: [] as string[] };
|
|
138
|
+
if (bucket.first === "") bucket.first = `${fam.id} "${evidenceLine(text, fam.pattern)}"`;
|
|
139
|
+
const label = XMGR_LABELS[fam.id];
|
|
140
|
+
if (label !== undefined) bucket.labels.push(label);
|
|
141
|
+
byRule.set(fam.rule, bucket);
|
|
142
|
+
}
|
|
143
|
+
const hits: ResidueHit[] = [];
|
|
144
|
+
for (const [rule, { first, labels }] of byRule) {
|
|
145
|
+
const severity = RESIDUE_SEVERITIES[rule];
|
|
146
|
+
if (severity === undefined) continue;
|
|
147
|
+
const message =
|
|
148
|
+
rule === RESIDUE_XMGR_RULE
|
|
149
|
+
? `${w.what} code in '${fileRel}' spawns foreign package manager(s): ${labels.join(", ")} — writes land outside the ${w.ledger} ledger and re-run on every consumer install (aihr incident shape)`
|
|
150
|
+
: rule === RESIDUE_T2_RULE
|
|
151
|
+
? `install-time code in '${fileRel}' reaches the network: ${first} — remote bytes run on every consumer install (T2); pin prebuilt artifacts instead`
|
|
152
|
+
: `install-time code in '${fileRel}' writes outside the package root: ${first} (T3)`;
|
|
153
|
+
hits.push({ rule, severity, message, fileRel });
|
|
154
|
+
}
|
|
155
|
+
return hits;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** B2: slice the first-argument expression text of the call whose `(` sits at `open` —
|
|
159
|
+
* paren/bracket/brace aware, quote aware, stopping at the top-level `,` or the closing `)`.
|
|
160
|
+
* Returns null on unbalanced input, which callers treat as an unrecognized shape (fail closed).
|
|
161
|
+
* This is deliberately ONE argument slice for a set membership test, not an expression parser. */
|
|
162
|
+
export function firstArgument(text: string, open: number): string | null {
|
|
163
|
+
let depth = 0;
|
|
164
|
+
let quote: string | null = null;
|
|
165
|
+
for (let i = open; i < text.length; i += 1) {
|
|
166
|
+
const ch = text.charAt(i);
|
|
167
|
+
if (quote !== null) {
|
|
168
|
+
if (ch === "\\") i += 1;
|
|
169
|
+
else if (ch === quote) quote = null;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
173
|
+
else if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
174
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
175
|
+
depth -= 1;
|
|
176
|
+
if (depth === 0) return text.slice(open + 1, i);
|
|
177
|
+
} else if (ch === "," && depth === 1) {
|
|
178
|
+
return text.slice(open + 1, i);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Concatenated bodies of every TOML section whose header line matches `header`
|
|
185
|
+
* (R3a scoping: pyproject [build-system] / cargo [..build-dependencies] only —
|
|
186
|
+
* a whole-manifest scan would false-positive on runtime [project]/[dependencies] entries). */
|
|
187
|
+
export function sectionBlock(text: string, header: RegExp): string {
|
|
188
|
+
const body: string[] = [];
|
|
189
|
+
let keep = false;
|
|
190
|
+
for (const line of text.split("\n")) {
|
|
191
|
+
if (line.startsWith("[")) keep = header.test(line);
|
|
192
|
+
else if (keep) body.push(line);
|
|
193
|
+
}
|
|
194
|
+
return body.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** §57/plan-line-59 shared T4 primitive row (npm sweep and pypi/crates legs attribute per archive). */
|
|
198
|
+
export function t4HitFor(rel: string, text: string): ResidueHit | null {
|
|
199
|
+
const t4 = FAMILY_PATTERNS.find((fam) => fam.rule === RESIDUE_T4_RULE && fam.pattern.test(text));
|
|
200
|
+
if (t4 === undefined) return null;
|
|
201
|
+
return {
|
|
202
|
+
rule: RESIDUE_T4_RULE,
|
|
203
|
+
severity: "CRITICAL",
|
|
204
|
+
message: `packed file '${rel}' references an OS persistence primitive: ${t4.id} "${evidenceLine(text, t4.pattern)}" — runs on login/boot long after uninstall; blocked artifact-wide regardless of hook linkage (T4)`,
|
|
205
|
+
fileRel: rel,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Signature #1 evaluation — ALL §1.6 clauses or null (whitespace-normalized literal matching). */
|
|
210
|
+
function evaluateT1(pkgDir: string, hook: string): string | null {
|
|
211
|
+
const trimmed = hook.trim();
|
|
212
|
+
const grammar = T1_HOOK_GRAMMAR.exec(trimmed);
|
|
213
|
+
if (grammar === null || !isPlainHookString(trimmed)) return null;
|
|
214
|
+
const name = grammar[1];
|
|
215
|
+
if (name === undefined) return null;
|
|
216
|
+
const text = readText(pkgDir, name);
|
|
217
|
+
if (text === null) return null; // missing/unreadable/binary ⇒ UNKNOWN ⇒ CRITICAL (fail-closed)
|
|
218
|
+
if (text.split("\n").length - 1 > T1_LINE_CAP) return null; // §1.6-8 cap (189 lines in the anatomy)
|
|
219
|
+
const norm = text.replace(/\s+/g, " ");
|
|
220
|
+
for (const lit of T1_REQUIRED_LITERALS) if (!norm.includes(lit)) return null; // §1.6-3/5/6/7 + adj #1
|
|
221
|
+
for (const mark of T1_FORBIDDEN_USER_PATH_MARKS) if (norm.includes(mark)) return null; // §1.6-5
|
|
222
|
+
if (T1_METAPROGRAMMING.test(norm)) return null; // §1.6-9 (NB1): eval/new Function/globalThis[] never appear in the anatomy
|
|
223
|
+
if (!verbsAllCalled(T1_WRITE_NAME, norm)) return null; // §1.6-9 (NB2): an un-called write-family name is an alias — out of grammar
|
|
224
|
+
if (!verbsAllCalled(T1_SPAWN_NAME, norm)) return null; // §1.6-9 (NB3): same doctrine for spawn verbs — kills `const ex = cp.exec; ex(…)`
|
|
225
|
+
for (const m of text.matchAll(T1_MODULE_REF)) {
|
|
226
|
+
const spec = (m[1] ?? m[2] ?? m[3] ?? "").replace(/^node:/, "");
|
|
227
|
+
if (!T1_ALLOWED_MODULES.includes(spec)) return null; // §1.6-2
|
|
228
|
+
}
|
|
229
|
+
for (const m of norm.matchAll(T1_DYNAMIC_REF)) {
|
|
230
|
+
const spec = (m[1] ?? "").trim();
|
|
231
|
+
const lit = /^"([^"]+)"$|^'([^']+)'$/.exec(spec);
|
|
232
|
+
const mod = (lit?.[1] ?? lit?.[2] ?? "").replace(/^node:/, "");
|
|
233
|
+
if (lit === null || !T1_ALLOWED_MODULES.includes(mod)) return null; // §1.6-2 exhaustive (B1)
|
|
234
|
+
}
|
|
235
|
+
for (const m of norm.matchAll(T1_WRITE_CALL)) {
|
|
236
|
+
const argv0 = firstArgument(norm, (m.index ?? 0) + m[0].length - 1);
|
|
237
|
+
if (argv0 === null || !T1_WRITE_ARGV0_FORMS.includes(argv0.replace(/\s+/g, ""))) return null; // §1.6-5 closed anatomy (B2)
|
|
238
|
+
}
|
|
239
|
+
for (const m of norm.matchAll(T1_SPAWN_CALL)) {
|
|
240
|
+
const literal = m[1];
|
|
241
|
+
const ident = m[2];
|
|
242
|
+
if (literal !== undefined) {
|
|
243
|
+
if (!T1_SPAWN_ARGV0_LITERALS.includes(literal)) return null; // §1.6-4
|
|
244
|
+
} else if (ident !== undefined) {
|
|
245
|
+
const bound = T1_SPAWN_ARGV0_IDENTIFIERS.find((b) => b.ident === ident);
|
|
246
|
+
if (bound === undefined || !norm.includes(bound.proof)) return null;
|
|
247
|
+
} else {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const m of text.matchAll(T1_ABS_PATH_LITERAL)) {
|
|
252
|
+
const rest = m[1];
|
|
253
|
+
if (rest === undefined || !T1_ALLOWED_ABS_PATHS.some((p) => rest.startsWith(p))) return null;
|
|
254
|
+
}
|
|
255
|
+
if (familyHits(name, text).length > 0) return null; // §1.6-5: any T2/T3/xmgr hit voids T1
|
|
256
|
+
// T4 mentions (even in comments) void the signature too — the artifact-wide sweep
|
|
257
|
+
// will still block independently, but a T1 badge must never ride on a persistence string.
|
|
258
|
+
if (FAMILY_PATTERNS.some((fam) => fam.rule === RESIDUE_T4_RULE && fam.pattern.test(text))) return null;
|
|
259
|
+
return RESIDUE_T1_SIGNATURE_ID;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function scanNpmLifecycleHook(o: { pkgDir: string; hook: string }): HookScan {
|
|
263
|
+
const signatureId = evaluateT1(o.pkgDir, o.hook);
|
|
264
|
+
let hits: ResidueHit[] = [];
|
|
265
|
+
if (signatureId === null && isPlainHookString(o.hook.trim())) {
|
|
266
|
+
const resolved = RESOLVABLE_HOOK.exec(o.hook.trim());
|
|
267
|
+
const rel = resolved?.[1];
|
|
268
|
+
if (rel !== undefined) {
|
|
269
|
+
const text = readText(o.pkgDir, rel);
|
|
270
|
+
if (text !== null) hits = familyHits(rel, text); // total reads stay ≤2 files/hook
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { signatureId, hits };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function isSweepable(rel: string): boolean {
|
|
277
|
+
return SWEEP_EXTS.has(rel.split(".").pop() ?? "") || rel.startsWith("bin/");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Artifact-wide T4 sweep + B2 marker pair-detection (§5.3 three static conditions, detection-only). */
|
|
281
|
+
export function sweepResidueNpmArtifact(o: { pkgDir: string; files: readonly string[]; binRels: readonly string[] }): ResidueHit[] {
|
|
282
|
+
// §5.3(i) writer files / (ii) deletion-restore files, keyed by marker id-path bytes
|
|
283
|
+
const hits: ResidueHit[] = [];
|
|
284
|
+
const writes = new Map<string, string>();
|
|
285
|
+
const removedIn = new Map<string, string>();
|
|
286
|
+
let inverse = false;
|
|
287
|
+
|
|
288
|
+
for (const entry of o.files) {
|
|
289
|
+
const rel = toPkgRel(entry);
|
|
290
|
+
if (!isSweepable(rel)) continue;
|
|
291
|
+
const text = readText(o.pkgDir, rel);
|
|
292
|
+
if (text === null) continue;
|
|
293
|
+
|
|
294
|
+
const t4 = t4HitFor(rel, text);
|
|
295
|
+
if (t4 !== null) hits.push(t4);
|
|
296
|
+
|
|
297
|
+
const hasWrite = MARKER_WRITE_SHAPE.test(text);
|
|
298
|
+
const hasRemove = MARKER_REMOVE_SHAPE.test(text);
|
|
299
|
+
for (const m of text.matchAll(new RegExp(MARKER_BEGIN.source, "g"))) {
|
|
300
|
+
const id = m[1];
|
|
301
|
+
if (id === undefined) continue;
|
|
302
|
+
if (hasWrite) writes.set(id, writes.get(id) ?? rel);
|
|
303
|
+
else if (hasRemove) removedIn.set(id, rel); // BEGIN bytes appear only in a removal shape ⇒ dead inverse
|
|
304
|
+
}
|
|
305
|
+
if (hasRemove) {
|
|
306
|
+
for (const m of text.matchAll(new RegExp(MARKER_END.source, "g"))) {
|
|
307
|
+
const id = m[1];
|
|
308
|
+
if (id !== undefined) removedIn.set(id, rel);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const binRel of o.binRels) {
|
|
314
|
+
const text = readText(o.pkgDir, toPkgRel(binRel));
|
|
315
|
+
if (text !== null && MARKER_INVERSE_SURFACE.test(text)) inverse = true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const [id, fileRel] of writes) {
|
|
319
|
+
const paired = removedIn.has(id) && inverse;
|
|
320
|
+
hits.push({
|
|
321
|
+
rule: RESIDUE_T3_RULE,
|
|
322
|
+
severity: "HIGH",
|
|
323
|
+
message:
|
|
324
|
+
`install-time code in '${fileRel}' writes a '# BEGIN ${id}' marker block outside the package root (T3)` +
|
|
325
|
+
(paired ? ` [pairing=verified: marker id '${id}' ships a remove path + CLI inverse; severity stays HIGH until 0.4.0]` : ""),
|
|
326
|
+
fileRel,
|
|
327
|
+
});
|
|
328
|
+
if (!paired) {
|
|
329
|
+
const missing = !removedIn.has(id) && !inverse ? "no remove path or CLI inverse" : !removedIn.has(id) ? "no remove path" : "no CLI inverse surface";
|
|
330
|
+
hits.push({
|
|
331
|
+
rule: RESIDUE_PAIRING_RULE,
|
|
332
|
+
severity: "HIGH",
|
|
333
|
+
message: `marker block '${id}' is written by '${fileRel}' but ${missing} ships in the artifact — the out-of-tree write has no undo surface (contract §5.3)`,
|
|
334
|
+
fileRel,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const [id, fileRel] of removedIn) {
|
|
339
|
+
if (writes.has(id)) continue;
|
|
340
|
+
hits.push({
|
|
341
|
+
rule: RESIDUE_PAIRING_RULE,
|
|
342
|
+
severity: "MEDIUM",
|
|
343
|
+
message: `'${fileRel}' removes/undoes marker block '${id}' that no packed file ever writes (dead inverse) — audit for a stripped or hidden write path (contract §5.3 note)`,
|
|
344
|
+
fileRel,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return hits;
|
|
348
|
+
}
|