@mmerterden/multi-agent-toolkit-mcp 3.11.0 → 3.13.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/CHANGELOG.md +154 -0
- package/README.md +6 -3
- package/README.tr.md +6 -3
- package/index.js +760 -32
- package/package.json +9 -3
- package/tools/context/index.js +175 -0
- package/tools/ios-app-store-audit/context.js +160 -9
- package/tools/ios-app-store-audit/index.js +24 -1
- package/tools/offload/index.js +28 -8
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for iOS Simulator, Android Emulator and headless web control.
|
|
3
|
+
"version": "3.13.0",
|
|
4
|
+
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 115 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js",
|
|
13
|
-
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
13
|
+
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -73,6 +73,12 @@
|
|
|
73
73
|
"CHANGELOG.md",
|
|
74
74
|
"LICENSE"
|
|
75
75
|
],
|
|
76
|
+
"_overridesReadme": "Three advisories reach this tree only through @modelcontextprotocol/sdk, and all three are fixed inside the major their consumer already depends on. The upper bound on fast-uri is load-bearing: ajv@8 declares ^3, and an unbounded >=3.1.6 resolves to 4.x, which is a different major under a dependency that never asked for it.",
|
|
77
|
+
"overrides": {
|
|
78
|
+
"fast-uri": ">=3.1.6 <4",
|
|
79
|
+
"hono": ">=4.13.5 <5",
|
|
80
|
+
"qs": ">=6.16.0 <7"
|
|
81
|
+
},
|
|
76
82
|
"dependencies": {
|
|
77
83
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
78
84
|
"pixelmatch": "^7.2.0",
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Full-text index over offloaded payloads.
|
|
2
|
+
//
|
|
3
|
+
// The offload module already writes a large result to a file and hands back a
|
|
4
|
+
// head+tail window plus the path, and `agent_query_output` greps that file.
|
|
5
|
+
// Grep answers "which lines contain this string"; it cannot answer "which part
|
|
6
|
+
// of this is about X", and it has no notion of one passage being a better match
|
|
7
|
+
// than another. For a 40k-line log that is the difference between reading the
|
|
8
|
+
// right 60 lines and reading the first 60 that happen to contain the word.
|
|
9
|
+
//
|
|
10
|
+
// So the same file is also indexed: chunked, stored in SQLite FTS5, and ranked
|
|
11
|
+
// with bm25(). node:sqlite ships with Node, so this costs no dependency.
|
|
12
|
+
//
|
|
13
|
+
// Three layers, cheapest first:
|
|
14
|
+
// context_search ranked snippets, ~50-100 tokens, enough to decide
|
|
15
|
+
// context_get one chunk in full, by the id search returned
|
|
16
|
+
// the file path everything, which is what the caller already had
|
|
17
|
+
//
|
|
18
|
+
// agent_query_output is untouched. It is in the cross-CLI contract and removing
|
|
19
|
+
// it would be a breaking change; this sits beside it.
|
|
20
|
+
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
|
|
27
|
+
// Overridable so a gate can index a fixture corpus without writing into the
|
|
28
|
+
// caller's real index, and so the ranking it asserts is over a known set rather
|
|
29
|
+
// than over whatever that machine happened to offload last.
|
|
30
|
+
export const INDEX_DIR =
|
|
31
|
+
process.env.MCP_TOOLKIT_INDEX_DIR ||
|
|
32
|
+
join(homedir(), ".claude", "logs", "multi-agent-toolkit", ".index");
|
|
33
|
+
|
|
34
|
+
// Chunks are lines, not bytes: a log line cut in half matches nothing and reads
|
|
35
|
+
// as nonsense. 40 lines is small enough that a hit points at a place and large
|
|
36
|
+
// enough to carry its own context.
|
|
37
|
+
export const CHUNK_LINES = 40;
|
|
38
|
+
export const CHUNK_OVERLAP = 8;
|
|
39
|
+
|
|
40
|
+
function dbPathFor(dir) {
|
|
41
|
+
return join(dir, "context.db");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function openIndex(dir = INDEX_DIR) {
|
|
45
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
46
|
+
const db = new DatabaseSync(dbPathFor(dir));
|
|
47
|
+
db.exec(`
|
|
48
|
+
create table if not exists docs (
|
|
49
|
+
id integer primary key,
|
|
50
|
+
path text not null,
|
|
51
|
+
mtime integer not null,
|
|
52
|
+
size integer not null,
|
|
53
|
+
unique(path)
|
|
54
|
+
);
|
|
55
|
+
create virtual table if not exists chunks using fts5(
|
|
56
|
+
body,
|
|
57
|
+
doc_id unindexed,
|
|
58
|
+
first_line unindexed,
|
|
59
|
+
last_line unindexed
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function chunk(text, lines = CHUNK_LINES, overlap = CHUNK_OVERLAP) {
|
|
66
|
+
const all = String(text).split("\n");
|
|
67
|
+
const out = [];
|
|
68
|
+
const step = Math.max(1, lines - overlap);
|
|
69
|
+
for (let start = 0; start < all.length; start += step) {
|
|
70
|
+
const slice = all.slice(start, start + lines);
|
|
71
|
+
if (!slice.join("").trim()) continue;
|
|
72
|
+
out.push({ body: slice.join("\n"), firstLine: start + 1, lastLine: start + slice.length });
|
|
73
|
+
if (start + lines >= all.length) break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Re-indexing an unchanged file is wasted work AND duplicate hits, so the
|
|
79
|
+
// (mtime, size) pair decides. It is not a hash: hashing a 40 MB log to learn
|
|
80
|
+
// nothing changed costs more than the indexing it saves.
|
|
81
|
+
export function indexFile(db, path) {
|
|
82
|
+
if (!existsSync(path)) return { indexed: false, reason: "no such file" };
|
|
83
|
+
const st = statSync(path);
|
|
84
|
+
const existing = db.prepare("select id, mtime, size from docs where path = ?").get(path);
|
|
85
|
+
if (existing && existing.mtime === Math.floor(st.mtimeMs) && existing.size === st.size) {
|
|
86
|
+
const n = db.prepare("select count(*) as n from chunks where doc_id = ?").get(existing.id);
|
|
87
|
+
return { indexed: false, reason: "unchanged", docId: existing.id, chunks: n?.n ?? 0 };
|
|
88
|
+
}
|
|
89
|
+
if (existing) {
|
|
90
|
+
db.prepare("delete from chunks where doc_id = ?").run(existing.id);
|
|
91
|
+
db.prepare("delete from docs where id = ?").run(existing.id);
|
|
92
|
+
}
|
|
93
|
+
db.prepare("insert into docs (path, mtime, size) values (?, ?, ?)").run(
|
|
94
|
+
path,
|
|
95
|
+
Math.floor(st.mtimeMs),
|
|
96
|
+
st.size,
|
|
97
|
+
);
|
|
98
|
+
const docId = db.prepare("select id from docs where path = ?").get(path).id;
|
|
99
|
+
const insert = db.prepare(
|
|
100
|
+
"insert into chunks (body, doc_id, first_line, last_line) values (?, ?, ?, ?)",
|
|
101
|
+
);
|
|
102
|
+
const pieces = chunk(readFileSync(path, "utf8"));
|
|
103
|
+
for (const p of pieces) insert.run(p.body, docId, p.firstLine, p.lastLine);
|
|
104
|
+
return { indexed: true, docId, chunks: pieces.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// FTS5 treats a bare `-` or an unbalanced quote as syntax, and a caller typing
|
|
108
|
+
// a log line into the query gets a SQL error instead of results. Quoting each
|
|
109
|
+
// term makes every query a literal phrase search, which is what a caller
|
|
110
|
+
// searching a log actually means.
|
|
111
|
+
export function toMatchQuery(query) {
|
|
112
|
+
const terms = String(query)
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.map((t) => t.replace(/"/g, ""))
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
if (!terms.length) return null;
|
|
117
|
+
return terms.map((t) => `"${t}"`).join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function search(db, query, { limit = 5, path = null } = {}) {
|
|
121
|
+
const match = toMatchQuery(query);
|
|
122
|
+
if (!match) return [];
|
|
123
|
+
const where = path
|
|
124
|
+
? "where chunks match ? and doc_id = (select id from docs where path = ?)"
|
|
125
|
+
: "where chunks match ?";
|
|
126
|
+
const params = path ? [match, path] : [match];
|
|
127
|
+
const rows = db
|
|
128
|
+
.prepare(
|
|
129
|
+
`select chunks.rowid, doc_id, first_line, last_line, body,
|
|
130
|
+
(select path from docs where docs.id = chunks.doc_id) as path,
|
|
131
|
+
bm25(chunks) as score
|
|
132
|
+
from chunks ${where} order by score limit ?`,
|
|
133
|
+
)
|
|
134
|
+
.all(...params, limit);
|
|
135
|
+
return rows.map((r) => ({
|
|
136
|
+
id: r.rowid,
|
|
137
|
+
docId: r.doc_id,
|
|
138
|
+
path: r.path,
|
|
139
|
+
firstLine: r.first_line,
|
|
140
|
+
lastLine: r.last_line,
|
|
141
|
+
score: r.score,
|
|
142
|
+
snippet: snippetOf(r.body, query),
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A window around the first matching line, not the head of the chunk: the
|
|
147
|
+
// caller is deciding whether to open this, and the head of a 40-line chunk
|
|
148
|
+
// often says nothing about why it matched.
|
|
149
|
+
export function snippetOf(body, query, radius = 2) {
|
|
150
|
+
const lines = String(body).split("\n");
|
|
151
|
+
const needles = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
152
|
+
let hit = lines.findIndex((l) => needles.some((n) => l.toLowerCase().includes(n)));
|
|
153
|
+
if (hit < 0) hit = 0;
|
|
154
|
+
const from = Math.max(0, hit - radius);
|
|
155
|
+
return lines.slice(from, hit + radius + 1).join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getChunk(db, id) {
|
|
159
|
+
const row = db
|
|
160
|
+
.prepare(
|
|
161
|
+
`select c.rowid as id, c.body, c.first_line, c.last_line, d.path
|
|
162
|
+
from chunks c join docs d on d.id = c.doc_id where c.rowid = ?`,
|
|
163
|
+
)
|
|
164
|
+
.get(Number(id));
|
|
165
|
+
return row ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function docPathId(path) {
|
|
169
|
+
return createHash("sha256").update(String(path)).digest("hex").slice(0, 12);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function ensureParent(path) {
|
|
173
|
+
const parent = dirname(path);
|
|
174
|
+
if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
|
|
175
|
+
}
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
* 18 rules consume. Direct port of XCArchiveParser.swift +
|
|
4
4
|
* InfoPlistParser.swift + PrivacyManifestParser.swift + EntitlementParser.swift.
|
|
5
5
|
*
|
|
6
|
-
* Plist parsing
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Plist parsing prefers `plutil -convert json` (system binary on macOS, ships
|
|
7
|
+
* with the OS) - more reliable than any Node `plist` package across the long
|
|
8
|
+
* tail of Apple plist quirks (binary plists, XML-with-DTD, mixed array/dict
|
|
9
|
+
* roots).
|
|
10
|
+
*
|
|
11
|
+
* When plutil is absent it falls back to a small XML reader, and when even that
|
|
12
|
+
* cannot apply - a binary plist off macOS - it records the miss rather than
|
|
13
|
+
* returning an empty object. That distinction is the whole point: every rule
|
|
14
|
+
* reads `infoPlist`, so a silent `{}` makes an unreadable archive audit exactly
|
|
15
|
+
* like a compliant one, and the tool answers PASS because it saw nothing. It
|
|
16
|
+
* shipped that way; a run with no plutil on PATH reported zero violations on a
|
|
17
|
+
* deliberately non-compliant fixture.
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
20
|
import { execSync } from "child_process";
|
|
13
|
-
import { existsSync, readdirSync, statSync } from "fs";
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
14
22
|
import { shq } from "./models.js";
|
|
15
23
|
import { join, basename } from "path";
|
|
16
24
|
|
|
@@ -23,16 +31,159 @@ function run(cmd) {
|
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
/**
|
|
34
|
+
/** True when the `plutil` binary is on PATH. Probed once. */
|
|
35
|
+
let _hasPlutil = null;
|
|
36
|
+
export function hasPlutil() {
|
|
37
|
+
if (_hasPlutil === null) _hasPlutil = run("command -v plutil") !== "";
|
|
38
|
+
return _hasPlutil;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Plists this process could not read, as absolute paths. A rule that finds
|
|
43
|
+
* nothing in an unread plist is not evidence of compliance, and runAudit turns
|
|
44
|
+
* a non-empty list into a finding rather than letting the verdict stand.
|
|
45
|
+
*/
|
|
46
|
+
export const unreadablePlists = new Set();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Empty the record. runAudit calls this before it parses anything, so the set
|
|
50
|
+
* describes the run being reported and not every run this process has done.
|
|
51
|
+
*/
|
|
52
|
+
export function resetUnreadablePlists() {
|
|
53
|
+
unreadablePlists.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decode the XML entities a plist can carry. */
|
|
57
|
+
function unescapeXml(s) {
|
|
58
|
+
return s
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/'/g, "'")
|
|
63
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
|
64
|
+
.replace(/&/g, "&");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal XML-plist reader for hosts without plutil.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately small: it handles the element set an Info.plist, an entitlements
|
|
71
|
+
* file and a PrivacyInfo.xcprivacy actually use. It is not a general plist
|
|
72
|
+
* implementation and makes no attempt at binary plists - those return null and
|
|
73
|
+
* are recorded as unreadable, which is the honest answer.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} xml
|
|
76
|
+
* @returns {object|Array|null}
|
|
77
|
+
*/
|
|
78
|
+
export function parseXmlPlist(xml) {
|
|
79
|
+
// <!DOCTYPE ...> and <?xml ...?> carry nothing we need and complicate the scan.
|
|
80
|
+
const body = xml.replace(/<\?xml[^>]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "");
|
|
81
|
+
const tokens = body.match(/<\/?[A-Za-z]+(?:\s[^>]*)?\/?>|[^<]+/g);
|
|
82
|
+
if (!tokens) return null;
|
|
83
|
+
let i = 0;
|
|
84
|
+
|
|
85
|
+
const next = () => tokens[i++];
|
|
86
|
+
const peek = () => tokens[i];
|
|
87
|
+
|
|
88
|
+
function readValue(tag) {
|
|
89
|
+
switch (tag) {
|
|
90
|
+
case "dict": {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (;;) {
|
|
93
|
+
const t = peek();
|
|
94
|
+
if (t === undefined || t === "</dict>") {
|
|
95
|
+
next();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
if (t === "<key>") {
|
|
99
|
+
next();
|
|
100
|
+
const k = unescapeXml(String(next() ?? "").trim());
|
|
101
|
+
next(); // </key>
|
|
102
|
+
const vt = String(next() ?? "").trim();
|
|
103
|
+
out[k] = readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
|
|
104
|
+
} else {
|
|
105
|
+
next();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
case "array": {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (;;) {
|
|
112
|
+
const t = peek();
|
|
113
|
+
if (t === undefined || t === "</array>") {
|
|
114
|
+
next();
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
const vt = String(next() ?? "").trim();
|
|
118
|
+
if (!vt.startsWith("<")) continue;
|
|
119
|
+
out.push(readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
case "string":
|
|
123
|
+
case "data":
|
|
124
|
+
case "date": {
|
|
125
|
+
const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "";
|
|
126
|
+
if (String(peek() ?? "").startsWith("</")) next();
|
|
127
|
+
return unescapeXml(raw);
|
|
128
|
+
}
|
|
129
|
+
case "integer":
|
|
130
|
+
case "real": {
|
|
131
|
+
const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "0";
|
|
132
|
+
if (String(peek() ?? "").startsWith("</")) next();
|
|
133
|
+
return Number(raw.trim());
|
|
134
|
+
}
|
|
135
|
+
case "true":
|
|
136
|
+
return true;
|
|
137
|
+
case "false":
|
|
138
|
+
return false;
|
|
139
|
+
default:
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (;;) {
|
|
145
|
+
const t = next();
|
|
146
|
+
if (t === undefined) return null;
|
|
147
|
+
const s = String(t).trim();
|
|
148
|
+
if (!s.startsWith("<plist")) continue;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const u = next();
|
|
151
|
+
if (u === undefined) return null;
|
|
152
|
+
const v = String(u).trim();
|
|
153
|
+
if (!v.startsWith("<")) continue;
|
|
154
|
+
return readValue(v.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Parse a binary or XML plist file into a JS object. */
|
|
27
160
|
export function parsePlist(plistPath) {
|
|
28
161
|
if (!existsSync(plistPath)) return null;
|
|
29
|
-
|
|
30
|
-
|
|
162
|
+
if (hasPlutil()) {
|
|
163
|
+
const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
|
|
164
|
+
if (json) {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(json);
|
|
167
|
+
} catch {
|
|
168
|
+
/* fall through to the XML reader */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let raw;
|
|
31
173
|
try {
|
|
32
|
-
|
|
174
|
+
raw = readFileSync(plistPath, "utf-8");
|
|
33
175
|
} catch {
|
|
176
|
+
unreadablePlists.add(plistPath);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
// A binary plist starts with the magic "bplist"; there is no reading it here.
|
|
180
|
+
if (raw.startsWith("bplist")) {
|
|
181
|
+
unreadablePlists.add(plistPath);
|
|
34
182
|
return null;
|
|
35
183
|
}
|
|
184
|
+
const parsed = parseXmlPlist(raw);
|
|
185
|
+
if (parsed === null) unreadablePlists.add(plistPath);
|
|
186
|
+
return parsed;
|
|
36
187
|
}
|
|
37
188
|
|
|
38
189
|
/** Recursively measure total size of a directory in bytes. */
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* the rest of the scan.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { parseArchive } from "./context.js";
|
|
24
|
+
import { hasPlutil, parseArchive, resetUnreadablePlists, unreadablePlists } from "./context.js";
|
|
25
25
|
import { compareSeverity } from "./models.js";
|
|
26
26
|
|
|
27
27
|
// ---------- Rule registry --------------------------------------------------
|
|
@@ -93,6 +93,7 @@ function resolveRuleSelection(rules) {
|
|
|
93
93
|
* @returns {Promise<object>} structured audit report
|
|
94
94
|
*/
|
|
95
95
|
export async function runAudit({ archivePath, rules = "all", options = {} } = {}) {
|
|
96
|
+
resetUnreadablePlists();
|
|
96
97
|
const ctx = parseArchive(archivePath);
|
|
97
98
|
|
|
98
99
|
const selectedIDs = new Set(resolveRuleSelection(rules));
|
|
@@ -134,6 +135,25 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// A rule that found nothing in a plist nobody could read has not measured
|
|
139
|
+
// anything, and the audit must not let that reach the caller as compliance.
|
|
140
|
+
// Every rule reads infoPlist, so one unread plist can silence all eighteen:
|
|
141
|
+
// the tool answered PASS on a deliberately non-compliant fixture the first
|
|
142
|
+
// time it ran on a host without plutil. Reported as an error so the verdict
|
|
143
|
+
// is not PASS, with the cause named - this is the tool's reach, not the
|
|
144
|
+
// archive's fault.
|
|
145
|
+
const unreadable = [...unreadablePlists].sort();
|
|
146
|
+
if (unreadable.length > 0) {
|
|
147
|
+
violations.push({
|
|
148
|
+
ruleID: "archive-readable",
|
|
149
|
+
severity: "error",
|
|
150
|
+
message: `${unreadable.length} plist(s) could not be read, so the rules below them measured nothing: ${unreadable.join(", ")}`,
|
|
151
|
+
suggestion: hasPlutil()
|
|
152
|
+
? "The files are present but neither plutil nor the XML reader could parse them. Re-export the archive, or open an issue with one of the files attached."
|
|
153
|
+
: "Binary plists need `plutil`, which ships with macOS. Run this audit on macOS, or convert the archive's plists to XML first (`plutil -convert xml1`).",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
// Sort: error → warning → info, then by ruleID alphabetically inside each.
|
|
138
158
|
violations.sort((a, b) => {
|
|
139
159
|
const c = compareSeverity(b.severity, a.severity);
|
|
@@ -156,6 +176,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
156
176
|
app: ctx.appName,
|
|
157
177
|
rulesRun: ranIDs,
|
|
158
178
|
rulesSkipped: skippedIDs,
|
|
179
|
+
// Explicit, so a caller can tell "clean" from "could not look".
|
|
180
|
+
measurable: unreadable.length === 0,
|
|
181
|
+
unreadablePlists: unreadable,
|
|
159
182
|
summary,
|
|
160
183
|
verdict,
|
|
161
184
|
violations,
|
package/tools/offload/index.js
CHANGED
|
@@ -58,15 +58,35 @@ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
|
|
|
58
58
|
}
|
|
59
59
|
})
|
|
60
60
|
.filter(Boolean)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
// Name as the tie-break, because mtime ties are not a corner case. APFS
|
|
62
|
+
// records nanoseconds, so five files written in a loop have five distinct
|
|
63
|
+
// stamps; ext4 on a CI runner records one, so they have the same stamp and
|
|
64
|
+
// "the newest" is whatever order readdir happened to return. Without this
|
|
65
|
+
// the retained set differs per filesystem.
|
|
66
|
+
.sort((a, b) => b.mtime - a.mtime || (a.full < b.full ? 1 : a.full > b.full ? -1 : 0));
|
|
67
|
+
|
|
68
|
+
// keepFiles is the size of the directory after the prune, not the size of
|
|
69
|
+
// the part of it retention got to decide. A promised file is one of the
|
|
70
|
+
// survivors, so it takes one of the slots - reserved up front rather than
|
|
71
|
+
// counted as the loop reaches it, because otherwise the answer depends on
|
|
72
|
+
// where that file happens to sort. It used to be neither: a keepSet entry
|
|
73
|
+
// was skipped without consuming anything, so keepFiles: 1 plus one promised
|
|
74
|
+
// file left TWO files behind. On APFS the promised file is genuinely the
|
|
75
|
+
// newest and sorted first, which hid it; on a filesystem whose mtimes tie it
|
|
76
|
+
// sorted anywhere and the directory kept a stale file forever.
|
|
77
|
+
const reserved = entries.filter((e) => keepSet.has(resolve(e.full))).length;
|
|
78
|
+
const budget = Math.max(0, keepFiles - reserved);
|
|
79
|
+
let kept = 0;
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (keepSet.has(resolve(entry.full))) continue;
|
|
82
|
+
const tooOld = entry.mtime < cutoff;
|
|
83
|
+
const tooMany = kept >= budget;
|
|
84
|
+
if (!tooOld && !tooMany) {
|
|
85
|
+
kept++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
68
88
|
try {
|
|
69
|
-
rmSync(
|
|
89
|
+
rmSync(entry.full, { recursive: true, force: true });
|
|
70
90
|
removed++;
|
|
71
91
|
} catch {
|
|
72
92
|
// A file another process holds open is skipped, not fatal.
|