readystack-play-declaration-gap-lint 1.0.4__py3-none-any.whl
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.
- readystack_play_declaration_gap_lint/__init__.py +1 -0
- readystack_play_declaration_gap_lint/__main__.py +12 -0
- readystack_play_declaration_gap_lint/js/cli.js +161 -0
- readystack_play_declaration_gap_lint/js/engine.js +120 -0
- readystack_play_declaration_gap_lint/js/license.js +103 -0
- readystack_play_declaration_gap_lint/js/package.json +41 -0
- readystack_play_declaration_gap_lint/js/rules.json +129 -0
- readystack_play_declaration_gap_lint/js/strings.json +10 -0
- readystack_play_declaration_gap_lint-1.0.4.dist-info/METADATA +84 -0
- readystack_play_declaration_gap_lint-1.0.4.dist-info/RECORD +14 -0
- readystack_play_declaration_gap_lint-1.0.4.dist-info/WHEEL +5 -0
- readystack_play_declaration_gap_lint-1.0.4.dist-info/entry_points.txt +2 -0
- readystack_play_declaration_gap_lint-1.0.4.dist-info/licenses/LICENSE.txt +15 -0
- readystack_play_declaration_gap_lint-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '1.0.4'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os, sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
# Runs the same checker as the npm package on the Node.js runtime installed with this package (nodejs-wheel-binaries).
|
|
6
|
+
from nodejs_wheel import node
|
|
7
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
8
|
+
return node([os.path.join(here, "js", "cli.js")] + sys.argv[1:])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
sys.exit(main())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.js — the same rules as the VS Code extension, run from a terminal or a container. Generated by adapters.py.
|
|
3
|
+
'use strict';
|
|
4
|
+
const fs = require('fs'), path = require('path');
|
|
5
|
+
const RULES = require('./rules.json');
|
|
6
|
+
const S = require('./strings.json');
|
|
7
|
+
const lic = require('./license.js');
|
|
8
|
+
const ENGINE = require('./engine.js'); // s144 — scaffold products: the same engine the VS Code extension runs
|
|
9
|
+
const RULE_LIST = Array.isArray(ENGINE.RULES) ? ENGINE.RULES : (Array.isArray(RULES) ? RULES : []);
|
|
10
|
+
const RULE_N = ENGINE.RULE_COUNT || RULE_LIST.length;
|
|
11
|
+
function scan(text, file) {
|
|
12
|
+
const r = ENGINE.engine.check(String(text), { today: new Date().toISOString().slice(0, 10), path: file || '' });
|
|
13
|
+
return (r && r.findings || []).map((f) => ({ line: parseInt(f.line, 10) || 1, msg: String(f.msg || f.message || f.check || ''), fix: f.fix || null, sev: (String(f.sev || 'error').toLowerCase().startsWith('err') ? 'error' : 'warn') }));
|
|
14
|
+
}
|
|
15
|
+
function isText(p) { try { const b = fs.readFileSync(p); if (b.length > 2 * 1024 * 1024) return false; const s = b.subarray(0, 4096); for (const x of s) if (x === 0) return false; return true; } catch (e) { return false; } }
|
|
16
|
+
function walk(dir, exts, out) {
|
|
17
|
+
let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
|
18
|
+
for (const e of ents) {
|
|
19
|
+
if (['node_modules', '.git', '.hg', '.svn', '.venv', 'venv', '.tox', '.cache', '.next', '.nuxt', '.terraform', '__pycache__'].includes(e.name)) continue; // s158 — .github · .gitlab-ci.yml · .env · .well-known 은 본다 (옛 줄은 점으로 시작하는 것을 전부 건너뛰어 워크플로 검사기가 .github/workflows 를 못 봤다)
|
|
20
|
+
const p = path.join(dir, e.name);
|
|
21
|
+
if (e.isDirectory()) walk(p, exts, out);
|
|
22
|
+
else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
27
|
+
function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
|
|
28
|
+
function render(rows, fmt) {
|
|
29
|
+
if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULE_N, files: rows }, null, 1);
|
|
30
|
+
if (fmt === 'csv') { const o = ['file,line,severity,message,fix']; for (const r of rows) for (const h of r.hits) o.push([csvq(r.file), h.line, h.sev, csvq(h.msg), csvq(h.fix)].join(',')); return o.join('\n') + '\n'; }
|
|
31
|
+
if (fmt === 'html') {
|
|
32
|
+
const n = rows.reduce((a, r) => a + r.hits.length, 0);
|
|
33
|
+
let o = '<!doctype html><meta charset="utf-8"><title>' + esc(S.name) + ' report</title><style>body{font:14px system-ui;margin:24px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:4px 8px;font-size:13px}code{font-family:ui-monospace,monospace}</style>';
|
|
34
|
+
o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULE_N + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
|
|
35
|
+
for (const r of rows) for (const h of r.hits) o += '<tr><td><code>' + esc(r.file) + '</code></td><td>' + h.line + '</td><td>' + esc(h.sev) + '</td><td>' + esc(h.msg) + '</td><td>' + esc(h.fix || '') + '</td></tr>';
|
|
36
|
+
return o + '</table>';
|
|
37
|
+
}
|
|
38
|
+
let o = ''; for (const r of rows) { o += r.file + '\n'; for (const h of r.hits) o += ' ' + String(h.line).padStart(5) + ' ' + h.sev.padEnd(5) + ' ' + h.msg + (h.fix ? '\n -> ' + h.fix : '') + '\n'; if (!r.hits.length) o += ' (no findings)\n'; }
|
|
39
|
+
return o;
|
|
40
|
+
}
|
|
41
|
+
function trialState() {
|
|
42
|
+
// s158 — ⚑7일 무료 없음: 새 체험은 ⛔열지 않는다 · 이미 시작된 체험(trial.json)만 끝까지 지킨다 (읽기 전용)
|
|
43
|
+
if (process.env.READYSTACK_NO_TRIAL) return { active: false };
|
|
44
|
+
try {
|
|
45
|
+
const p = path.join(path.dirname(lic.storePath()), S.bin + '.trial.json');
|
|
46
|
+
const t = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
47
|
+
return { active: !!(t && t.until) && new Date(t.until + 'T23:59:59Z').getTime() > Date.now(), until: t && t.until };
|
|
48
|
+
} catch (e) { return { active: false }; }
|
|
49
|
+
}
|
|
50
|
+
// ★s158 2026-09-23 — the free run SPEAKS about the rest of the folder (vsix: auto.js · crx: welcome tab).
|
|
51
|
+
// The answer for the named files is complete and free; the next step is shown with the user's OWN numbers:
|
|
52
|
+
// "this folder has N more matching files - M issues in K of them" + the one command that sweeps them.
|
|
53
|
+
// Never blocks, never starts a trial (s158: no free trial - the free answer on the named files is the "try it"), human seat only, 400 files / 1.5 s cap.
|
|
54
|
+
function folderHint(done) {
|
|
55
|
+
if (!process.stderr.isTTY || process.env.CI || process.env.READYSTACK_NO_HINT || !done.length) return;
|
|
56
|
+
const base = path.dirname(path.resolve(done[0]));
|
|
57
|
+
const seen = new Set(done.map((f) => path.resolve(f)));
|
|
58
|
+
const all = walk(base, S.exts || [], []).filter((f) => !seen.has(path.resolve(f)));
|
|
59
|
+
if (!all.length) return;
|
|
60
|
+
const t0 = Date.now(); let issues = 0, hitFiles = 0, scanned = 0;
|
|
61
|
+
for (const f of all.slice(0, 400)) { if (Date.now() - t0 > 1500) break; let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { continue; } scanned++; const h = scan(t, f); if (h.length) { issues += h.length; hitFiles++; } }
|
|
62
|
+
const rel = path.relative(process.cwd(), base) || '.';
|
|
63
|
+
const tail = trialState().active ? 'free during your trial' : '$' + S.price + ' once';
|
|
64
|
+
const more = all.length + ' more matching file' + (all.length > 1 ? 's' : '');
|
|
65
|
+
const found = issues ? ' - ' + (scanned < all.length ? 'at least ' : '') + issues + ' issue' + (issues > 1 ? 's' : '') + ' in ' + hitFiles + ' of them' : ' - no issues found in them';
|
|
66
|
+
process.stderr.write('\n' + (rel === '.' ? 'This folder' : rel) + ' has ' + more + found + '.\n' + (issues ? 'Sweep them all: ' + S.bin + ' --dir ' + (/\s/.test(rel) ? JSON.stringify(rel) : rel) + ' (' + tail + ')\n' : ''));
|
|
67
|
+
}
|
|
68
|
+
let _usePinged = false;
|
|
69
|
+
function pingUse() {
|
|
70
|
+
try {
|
|
71
|
+
if (_usePinged) return; _usePinged = true;
|
|
72
|
+
if (process.env.DO_NOT_TRACK === '1' || process.env.READYSTACK_NO_TELEMETRY || process.env.CI) return;
|
|
73
|
+
if (!(process.stdout.isTTY || process.stdin.isTTY)) return; // human seat only (s152)
|
|
74
|
+
const https = require('https');
|
|
75
|
+
const body = JSON.stringify({ t: 'use', slug: S.bin, src: 'cli', why: 'free' });
|
|
76
|
+
const req = https.request({ hostname: 'getreadystack.com', path: '/api/ev', method: 'POST', timeout: 3000,
|
|
77
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'user-agent': 'readystack-cli/' + S.bin } }, function (res) { res.resume(); });
|
|
78
|
+
req.on('timeout', function () { req.destroy(); }); req.on('error', function () {});
|
|
79
|
+
req.write(body); req.end();
|
|
80
|
+
} catch (e) {}
|
|
81
|
+
}
|
|
82
|
+
function mcpServe() {
|
|
83
|
+
process.env.READYSTACK_MCP = '1'; // s152 - 에이전트(Claude Code·Cursor)가 부른 세션은 사람 자리다 · 키 판 핑 src=mcp
|
|
84
|
+
// s144 — MCP server over stdio (newline-delimited JSON-RPC · no dependencies). Free: check_text · check_file. Licence (s158: no free trial): check_dir.
|
|
85
|
+
const rl = require('readline').createInterface({ input: process.stdin });
|
|
86
|
+
const send = (o) => process.stdout.write(JSON.stringify(o) + '\n');
|
|
87
|
+
const tools = [
|
|
88
|
+
{ name: 'check_text', description: S.name + ' - run all ' + RULE_N + ' checks on a text (free)', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'file contents' }, path: { type: 'string', description: 'optional file name for context' } }, required: ['text'] } },
|
|
89
|
+
{ name: 'check_file', description: S.name + ' - run all checks on one file by path (free)', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
|
|
90
|
+
{ name: 'check_dir', description: S.name + ' - sweep a folder and return every finding (licence; $' + S.price + ' once)', inputSchema: { type: 'object', properties: { dir: { type: 'string' }, ext: { type: 'string', description: 'optional extension filter, e.g. .html' } }, required: ['dir'] } }
|
|
91
|
+
];
|
|
92
|
+
const result = (id, rows) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: render(rows, 'text') }], structuredContent: { tool: S.name, rules: RULE_N, files: rows } } });
|
|
93
|
+
const fail = (id, text) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
|
|
94
|
+
rl.on('line', async (line) => {
|
|
95
|
+
let m; try { m = JSON.parse(line); } catch (e) { return; }
|
|
96
|
+
const id = m.id, method = m.method;
|
|
97
|
+
if (method === 'initialize') return send({ jsonrpc: '2.0', id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: '@readystack/' + S.bin, version: '1.0.0' } } });
|
|
98
|
+
if (method === 'notifications/initialized' || method === 'ping') { if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} }); return; }
|
|
99
|
+
if (method === 'tools/list') return send({ jsonrpc: '2.0', id, result: { tools } });
|
|
100
|
+
if (method === 'tools/call') {
|
|
101
|
+
const name = (m.params || {}).name, args = (m.params || {}).arguments || {};
|
|
102
|
+
try {
|
|
103
|
+
if (name === 'check_text') return result(id, [{ file: args.path || '(text)', hits: scan(String(args.text || ''), args.path || '') }]);
|
|
104
|
+
if (name === 'check_file') return result(id, [{ file: args.path, hits: scan(fs.readFileSync(args.path, 'utf8'), args.path) }]);
|
|
105
|
+
if (name === 'check_dir') {
|
|
106
|
+
const r = await lic.ensure();
|
|
107
|
+
if (!r.ok && !trialState().active) return fail(id, S.need_key + ' Get a licence ($' + S.price + ', once): ' + lic.BUY_URL);
|
|
108
|
+
const files = walk(args.dir, args.ext ? [args.ext] : (S.exts || []), []);
|
|
109
|
+
return result(id, files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t, f) }; }));
|
|
110
|
+
}
|
|
111
|
+
return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool ' + name } });
|
|
112
|
+
} catch (e) { return fail(id, String(e && e.message || e)); }
|
|
113
|
+
}
|
|
114
|
+
if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function help() {
|
|
118
|
+
return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
|
|
119
|
+
' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
|
|
120
|
+
' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
|
|
121
|
+
' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
|
|
122
|
+
' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
|
|
123
|
+
' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
|
|
124
|
+
' ' + S.bin + ' --mcp run as an MCP server (stdio) for Claude Code / Cursor / Windsurf - free checks, folder sweep needs a licence', '',
|
|
125
|
+
'Free: ' + S.free, 'Licence ($' + S.price + ', once): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
|
|
126
|
+
}
|
|
127
|
+
(async function main() {
|
|
128
|
+
try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { if (Array.isArray(ENGINE.RULES)) for (const r of _feed.rules) ENGINE.RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
|
|
129
|
+
const a = process.argv.slice(2);
|
|
130
|
+
const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
|
|
131
|
+
if (a.includes('--mcp')) { mcpServe(); return; }
|
|
132
|
+
if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
|
|
133
|
+
if (a.includes('--rules')) { process.stdout.write((RULE_LIST.length ? RULE_LIST.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + (r.message || r.msg || r.id || '')) : Array.from({ length: RULE_N }, (_, i) => String(i + 1).padStart(3) + ' [engine] check ' + (i + 1))).join('\n') + '\n'); return; }
|
|
134
|
+
if (a.includes('--license')) { const r = await lic.ensure(get('--license')); process.stdout.write(r.ok ? 'Licence stored: ' + lic.storePath() + '\n' : 'Licence not accepted (' + r.why + '). Get one: ' + lic.BUY_URL + '\n'); process.exit(r.ok ? 0 : 2); }
|
|
135
|
+
const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
|
|
136
|
+
const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
|
|
137
|
+
const paid = !!(dir || fmt || ci);
|
|
138
|
+
if (paid) {
|
|
139
|
+
const r = await lic.ensure();
|
|
140
|
+
if (!r.ok) {
|
|
141
|
+
const t = trialState(); // s158 — ⚑7일 무료 없음 · 이미 시작된 체험만 지킨다
|
|
142
|
+
if (t.active) process.stderr.write('Trial: the full run is free until ' + t.until + ' — after that $' + S.price + ' once. Get a licence: ' + lic.BUY_URL + '\n');
|
|
143
|
+
else {
|
|
144
|
+
// s158 — the key is asked WITH the customer's own count (endowment · open loop): how many issues this folder holds.
|
|
145
|
+
let own = '';
|
|
146
|
+
if (dir) { try { const all = walk(dir, exts, []); let n = 0, k = 0, sc = 0; const t0 = Date.now();
|
|
147
|
+
for (const f of all.slice(0, 2000)) { if (Date.now() - t0 > 4000) break; let tx = ''; try { tx = fs.readFileSync(f, 'utf8'); } catch (e) { continue; } sc++; const h = scan(tx, f); if (h.length) { n += h.length; k++; } }
|
|
148
|
+
if (n) own = dir + ': ' + (sc < all.length ? 'at least ' : '') + n + ' issue' + (n > 1 ? 's' : '') + ' in ' + k + ' of ' + all.length + ' files.\n'; } catch (e) {} }
|
|
149
|
+
process.stderr.write(own + S.need_key + '\n set READYSTACK_LICENSE=<key> or ' + S.bin + ' --license <key>\n Get a licence ($' + S.price + ' once): ' + lic.BUY_URL + '\n'); process.exit(2);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
|
|
154
|
+
if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
|
|
155
|
+
const rows = files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t) }; });
|
|
156
|
+
const text = render(rows, fmt || 'text');
|
|
157
|
+
if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
158
|
+
if (!paid) { try { folderHint(files); } catch (e) {} pingUse(); } // s158 — a free run ends with the user's own folder count + one anonymous 'used' count (human seat only)
|
|
159
|
+
const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
|
|
160
|
+
if (ci && errors) process.exit(1);
|
|
161
|
+
})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Play Declaration Gap Lint — the brain. Same file runs in Node (extension) and in the browser (free web check).
|
|
2
|
+
'use strict';
|
|
3
|
+
var RULES = (typeof module !== 'undefined' && module.exports) ? require('./rules.json') : window.PLAYDECL_RULES;
|
|
4
|
+
|
|
5
|
+
// foregroundServiceType -> the FOREGROUND_SERVICE_* permission Android 14 (API 34) demands for it.
|
|
6
|
+
var FGS_PERM = {
|
|
7
|
+
camera: 'FOREGROUND_SERVICE_CAMERA',
|
|
8
|
+
connectedDevice: 'FOREGROUND_SERVICE_CONNECTED_DEVICE',
|
|
9
|
+
dataSync: 'FOREGROUND_SERVICE_DATA_SYNC',
|
|
10
|
+
health: 'FOREGROUND_SERVICE_HEALTH',
|
|
11
|
+
location: 'FOREGROUND_SERVICE_LOCATION',
|
|
12
|
+
mediaPlayback: 'FOREGROUND_SERVICE_MEDIA_PLAYBACK',
|
|
13
|
+
mediaProcessing: 'FOREGROUND_SERVICE_MEDIA_PROCESSING',
|
|
14
|
+
mediaProjection: 'FOREGROUND_SERVICE_MEDIA_PROJECTION',
|
|
15
|
+
microphone: 'FOREGROUND_SERVICE_MICROPHONE',
|
|
16
|
+
phoneCall: 'FOREGROUND_SERVICE_PHONE_CALL',
|
|
17
|
+
remoteMessaging: 'FOREGROUND_SERVICE_REMOTE_MESSAGING',
|
|
18
|
+
specialUse: 'FOREGROUND_SERVICE_SPECIAL_USE',
|
|
19
|
+
systemExempted: 'FOREGROUND_SERVICE_SYSTEM_EXEMPTED'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
|
|
23
|
+
function flat(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); } // attributes wrap over lines in real manifests
|
|
24
|
+
function attr(tag, name) {
|
|
25
|
+
var m = new RegExp('android:' + name + '\\s*=\\s*"([^"]*)"').exec(tag);
|
|
26
|
+
return m ? m[1] : '';
|
|
27
|
+
}
|
|
28
|
+
// Commented-out permissions must not count. Keep the line count intact.
|
|
29
|
+
function stripComments(text) {
|
|
30
|
+
return String(text).replace(/<!--[\s\S]*?-->/g, function (c) { return c.replace(/[^\n]/g, ' '); });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function permissions(text) {
|
|
34
|
+
var out = [], re = /<uses-permission(?:-sdk-\d+)?\b([^>]*)>/g, m;
|
|
35
|
+
while ((m = re.exec(text))) {
|
|
36
|
+
var a = flat(m[1]), n = attr(a, 'name');
|
|
37
|
+
if (n) out.push({ name: n, line: lineOf(text, m.index) });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function services(text) {
|
|
43
|
+
var out = [], re = /<service\b([^>]*)>/g, m;
|
|
44
|
+
while ((m = re.exec(text))) {
|
|
45
|
+
var open = flat(m[1]);
|
|
46
|
+
var body = '';
|
|
47
|
+
if (!/\/$/.test(open)) {
|
|
48
|
+
var end = text.indexOf('</service>', re.lastIndex);
|
|
49
|
+
body = flat(text.slice(re.lastIndex, end === -1 ? text.length : end));
|
|
50
|
+
}
|
|
51
|
+
out.push({
|
|
52
|
+
line: lineOf(text, m.index),
|
|
53
|
+
name: attr(open, 'name'),
|
|
54
|
+
perm: attr(open, 'permission'),
|
|
55
|
+
fgs: attr(open, 'foregroundServiceType'),
|
|
56
|
+
body: body
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function check(text, opts) {
|
|
63
|
+
opts = opts || {};
|
|
64
|
+
var src = stripComments(String(text || ''));
|
|
65
|
+
var perms = permissions(src), svcs = services(src);
|
|
66
|
+
var have = {};
|
|
67
|
+
perms.forEach(function (p) { have[p.name] = p.line; });
|
|
68
|
+
var findings = [];
|
|
69
|
+
function push(rule, line, extra) {
|
|
70
|
+
findings.push({
|
|
71
|
+
check: rule.id, sev: rule.sev, line: line,
|
|
72
|
+
msg: (extra ? extra + ' ' : '') + rule.msg + ' [Play surface: ' + rule.form + ']'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
(RULES || []).forEach(function (rule) {
|
|
77
|
+
if (rule.kind === 'perm') {
|
|
78
|
+
if (have[rule.perm] !== undefined) push(rule, have[rule.perm], rule.perm.split('.').pop() + ' is declared.');
|
|
79
|
+
} else if (rule.kind === 'perm_any') {
|
|
80
|
+
(rule.perms || []).forEach(function (p) {
|
|
81
|
+
if (have[p] !== undefined) push(rule, have[p], p.split('.').pop() + ' is declared.');
|
|
82
|
+
});
|
|
83
|
+
} else if (rule.kind === 'perm_prefix') {
|
|
84
|
+
var hit = perms.filter(function (p) { return p.name.indexOf(rule.prefix) === 0; });
|
|
85
|
+
if (hit.length) push(rule, hit[0].line, hit.length + ' Health Connect permission' + (hit.length === 1 ? '' : 's') + ' declared (' + hit[0].name + (hit.length > 1 ? ', ...' : '') + ').');
|
|
86
|
+
} else if (rule.kind === 'service_perm') {
|
|
87
|
+
svcs.forEach(function (s) { if (s.perm === rule.perm) push(rule, s.line, (s.name || 'a service') + ':'); });
|
|
88
|
+
} else if (rule.kind === 'fgs_any') {
|
|
89
|
+
var types = {};
|
|
90
|
+
svcs.forEach(function (s) { if (s.fgs) s.fgs.split('|').forEach(function (t) { if (t) types[t.trim()] = s.line; }); });
|
|
91
|
+
var names = Object.keys(types);
|
|
92
|
+
if (names.length) push(rule, types[names[0]], names.length + ' foreground service type' + (names.length === 1 ? '' : 's') + ' in this manifest (' + names.join(', ') + ').');
|
|
93
|
+
} else if (rule.kind === 'fgs_permission') {
|
|
94
|
+
svcs.forEach(function (s) {
|
|
95
|
+
if (!s.fgs) return;
|
|
96
|
+
s.fgs.split('|').forEach(function (t) {
|
|
97
|
+
t = t.trim(); if (!t) return;
|
|
98
|
+
var need = FGS_PERM[t];
|
|
99
|
+
if (need && have['android.permission.' + need] === undefined) {
|
|
100
|
+
push(rule, s.line, 'foregroundServiceType="' + t + '" without android.permission.' + need + '.');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
} else if (rule.kind === 'special_use') {
|
|
105
|
+
svcs.forEach(function (s) {
|
|
106
|
+
if (s.fgs && s.fgs.split('|').map(function (t) { return t.trim(); }).indexOf('specialUse') !== -1
|
|
107
|
+
&& s.body.indexOf('android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE') === -1) {
|
|
108
|
+
push(rule, s.line, (s.name || 'this service') + ':');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
findings.sort(function (a, b) { return (a.line || 0) - (b.line || 0); });
|
|
115
|
+
return { findings: findings, permissions: perms.length, services: svcs.length };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
var API = { engine: { check: check }, RULES: RULES, RULE_COUNT: (RULES || []).length, FGS_PERM: FGS_PERM };
|
|
119
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = API;
|
|
120
|
+
else window.PLAYDECLENGINE = API;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// license.js — Polar licence check for the CLI / container. Generated by adapters.py; do not edit by hand.
|
|
2
|
+
'use strict';
|
|
3
|
+
const https = require('https'), fs = require('fs'), path = require('path'), os = require('os');
|
|
4
|
+
const ORG_ID = 'a5cdf664-d8e7-4f87-8895-056717aaba17';
|
|
5
|
+
const BENEFIT_ID = '099dfee9-9add-4848-b801-bbe8218e38b5'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
|
|
6
|
+
const BUY_URL = 'https://getreadystack.com/api/buy/cl/polar_cl_9pjxdAKOI372jyFZtBN3s5u8ajfGhvBhh1rwf3TGsGE';
|
|
7
|
+
const SLUG = 'play-declaration-gap-lint';
|
|
8
|
+
const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
|
|
9
|
+
const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
|
|
10
|
+
function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
|
|
11
|
+
function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
|
|
12
|
+
function save(o) { try { fs.mkdirSync(path.dirname(storePath()), { recursive: true }); fs.writeFileSync(storePath(), JSON.stringify(o)); } catch (e) { /* read-only home: still works for this run */ } }
|
|
13
|
+
const ALL_BENEFIT_ID = '22692551-5203-4467-b1a3-e33cdba6589d'; // s149 2026-09-17 — 팀 키(전 린터 한 키 · Polar benefit) · 상품 benefit 다음에 한 번 더 묻는다
|
|
14
|
+
// s170 2026-09-29 — ask OUR worker first (/api/lic): keys bought on Whop open here (a price-tier key binds to the first tool it opens · the team key opens every tool). Then the old Polar check.
|
|
15
|
+
function validate(key) {
|
|
16
|
+
return validateHub(key).then(function (h) {
|
|
17
|
+
if (h.ok) return h;
|
|
18
|
+
return validate1(key, BENEFIT_ID).then(function (r) { return (r.ok || r.offline || !/^[0-9a-f-]{36}$/.test(ALL_BENEFIT_ID)) ? r : validate1(key, ALL_BENEFIT_ID); })
|
|
19
|
+
.then(function (r) { return r.ok ? r : { ok: false, offline: !!(h.offline || r.offline) }; });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function validateHub(key) {
|
|
23
|
+
return new Promise(function (resolve) {
|
|
24
|
+
const req = https.request({ hostname: 'getreadystack.com', path: '/api/lic?key=' + encodeURIComponent(key) + '&slug=' + encodeURIComponent(SLUG),
|
|
25
|
+
method: 'GET', timeout: 8000, headers: { 'accept': 'application/json', 'user-agent': 'readystack-cli/' + SLUG } }, function (res) {
|
|
26
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
27
|
+
res.on('end', function () {
|
|
28
|
+
if (res.statusCode !== 200) return resolve({ ok: false, offline: res.statusCode >= 500 });
|
|
29
|
+
try { const j = JSON.parse(buf); resolve({ ok: !!(j && j.ok), offline: false }); } catch (e) { resolve({ ok: false, offline: false }); }
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
|
|
33
|
+
req.on('error', function () { resolve({ ok: false, offline: true }); });
|
|
34
|
+
req.end();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function validate1(key, ben) {
|
|
38
|
+
return new Promise(function (resolve) {
|
|
39
|
+
if (!ORG_ID) return resolve({ ok: false, offline: false });
|
|
40
|
+
const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(ben) ? { key: key, organization_id: ORG_ID, benefit_id: ben } : { key: key, organization_id: ORG_ID });
|
|
41
|
+
const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
|
|
42
|
+
headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
|
|
43
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
44
|
+
res.on('end', function () {
|
|
45
|
+
if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
|
|
46
|
+
try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
|
|
47
|
+
catch (e) { resolve({ ok: false, offline: false }); }
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
|
|
51
|
+
req.on('error', function () { resolve({ ok: false, offline: true }); });
|
|
52
|
+
req.write(body); req.end();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ★s151 2026-09-19 — 키 판(키가 없거나 거절된 순간)을 익명으로 센다 (슬러그·출처·이유만) · DO_NOT_TRACK=1 · READYSTACK_NO_TELEMETRY 면 안 보낸다 · 실패는 조용히 · 프로세스당 한 번.
|
|
56
|
+
let _pinged = false;
|
|
57
|
+
function pingPaywall(why) {
|
|
58
|
+
try {
|
|
59
|
+
if (_pinged) return; _pinged = true;
|
|
60
|
+
if (process.env.DO_NOT_TRACK === '1' || process.env.READYSTACK_NO_TELEMETRY || process.env.CI) return; // s151: CI(깃허브 액션 등)와 우리 빌드는 손님이 아니다
|
|
61
|
+
if (!(process.stdout.isTTY || process.stdin.isTTY || process.env.READYSTACK_MCP === '1')) return; // s152: 사람 자리(터미널·MCP 세션)에서만 센다 - 발행 1분 뒤 남의 실행기(JP · 우리 3대는 US)가 돌린 no_key 13건은 손님이 아니다
|
|
62
|
+
const body = JSON.stringify({ t: 'paywall', slug: SLUG, src: process.env.READYSTACK_MCP === '1' ? 'mcp' : 'cli', why: why || 'no_key' });
|
|
63
|
+
const req = https.request({ hostname: 'getreadystack.com', path: '/api/ev', method: 'POST', timeout: 3000,
|
|
64
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'user-agent': 'readystack-cli/' + SLUG } }, function (res) { res.resume(); });
|
|
65
|
+
req.on('timeout', function () { req.destroy(); }); req.on('error', function () {});
|
|
66
|
+
req.write(body); req.end();
|
|
67
|
+
} catch (e) { /* 세는 것이 실패해도 상품은 돈다 */ }
|
|
68
|
+
}
|
|
69
|
+
async function ensure(explicitKey) {
|
|
70
|
+
const st = load();
|
|
71
|
+
const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
|
|
72
|
+
if (!key) { pingPaywall('no_key'); return { ok: false, why: 'no_key' }; } // s151
|
|
73
|
+
const age = Date.now() - (st.okAt || 0);
|
|
74
|
+
if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
|
|
75
|
+
const r = await validate(String(key).trim());
|
|
76
|
+
if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
|
|
77
|
+
if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
|
|
78
|
+
if (!r.offline) pingPaywall('invalid'); // s151
|
|
79
|
+
return { ok: false, why: r.offline ? 'offline' : 'invalid' };
|
|
80
|
+
}
|
|
81
|
+
// ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
|
|
82
|
+
const FEED_URL = 'https://getreadystack.com/api/rules/';
|
|
83
|
+
function pullFeed() {
|
|
84
|
+
const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
|
|
85
|
+
if (!key) return Promise.resolve(cached);
|
|
86
|
+
if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
|
|
87
|
+
return new Promise(function (resolve) {
|
|
88
|
+
let req;
|
|
89
|
+
try {
|
|
90
|
+
req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
|
|
91
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
92
|
+
res.on('end', function () {
|
|
93
|
+
if (res.statusCode !== 200) return resolve(cached);
|
|
94
|
+
try { const j = JSON.parse(buf); if (!j || !Array.isArray(j.rules)) return resolve(cached); save(Object.assign(load(), { feed: j, feedAt: Date.now() })); resolve(j); }
|
|
95
|
+
catch (e) { resolve(cached); }
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
} catch (e) { return resolve(cached); }
|
|
99
|
+
req.on('timeout', function () { req.destroy(); resolve(cached); });
|
|
100
|
+
req.on('error', function () { resolve(cached); });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
module.exports = { ensure, BUY_URL, storePath, pullFeed };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@readystack/play-declaration-gap-lint",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "15 checks that name every Play Console declaration your manifest opens",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"androidmanifest",
|
|
11
|
+
"google play",
|
|
12
|
+
"play console",
|
|
13
|
+
"permissions",
|
|
14
|
+
"declaration",
|
|
15
|
+
"foreground service",
|
|
16
|
+
"android lint"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://getreadystack.com",
|
|
19
|
+
"funding": "https://getreadystack.com/api/buy/cl/polar_cl_9pjxdAKOI372jyFZtBN3s5u8ajfGhvBhh1rwf3TGsGE",
|
|
20
|
+
"bin": {
|
|
21
|
+
"play-declaration-gap-lint": "cli.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"mcpName": "io.github.jmshinhwa/play-declaration-gap-lint",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/jmshinhwa/readystack-themes.git",
|
|
30
|
+
"directory": "play-declaration-gap-lint"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"cli.js",
|
|
34
|
+
"license.js",
|
|
35
|
+
"rules.json",
|
|
36
|
+
"strings.json",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE.txt",
|
|
39
|
+
"engine.js"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "package_visibility",
|
|
4
|
+
"sev": "error",
|
|
5
|
+
"kind": "perm",
|
|
6
|
+
"perm": "android.permission.QUERY_ALL_PACKAGES",
|
|
7
|
+
"form": "App visibility permission declaration",
|
|
8
|
+
"msg": "QUERY_ALL_PACKAGES is a restricted permission: Play Console asks for the App visibility declaration and accepts it only for eligible use cases (browsers, launchers, security, device management). Otherwise list the packages you need under <queries> instead."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "all_files_access",
|
|
12
|
+
"sev": "error",
|
|
13
|
+
"kind": "perm",
|
|
14
|
+
"perm": "android.permission.MANAGE_EXTERNAL_STORAGE",
|
|
15
|
+
"form": "All files access permission declaration",
|
|
16
|
+
"msg": "MANAGE_EXTERNAL_STORAGE (All files access) needs the All files access declaration in Play Console and a core-functionality justification; file pickers and scoped storage are expected instead."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "background_location",
|
|
20
|
+
"sev": "error",
|
|
21
|
+
"kind": "perm",
|
|
22
|
+
"perm": "android.permission.ACCESS_BACKGROUND_LOCATION",
|
|
23
|
+
"form": "Location permissions declaration",
|
|
24
|
+
"msg": "ACCESS_BACKGROUND_LOCATION triggers the Location permissions declaration: Play wants the feature that needs background location, a video of it, and a prominent in-app disclosure before the request."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "sms_call_log",
|
|
28
|
+
"sev": "error",
|
|
29
|
+
"kind": "perm_any",
|
|
30
|
+
"perms": [
|
|
31
|
+
"android.permission.READ_SMS",
|
|
32
|
+
"android.permission.RECEIVE_SMS",
|
|
33
|
+
"android.permission.SEND_SMS",
|
|
34
|
+
"android.permission.READ_CALL_LOG",
|
|
35
|
+
"android.permission.WRITE_CALL_LOG",
|
|
36
|
+
"android.permission.PROCESS_OUTGOING_CALLS"
|
|
37
|
+
],
|
|
38
|
+
"form": "Permissions declaration (SMS and Call Log)",
|
|
39
|
+
"msg": "SMS and Call Log permissions are restricted: Play grants them mainly to the app the user has set as default handler, and every other use needs the Permissions declaration with an approved exception."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "install_packages",
|
|
43
|
+
"sev": "error",
|
|
44
|
+
"kind": "perm",
|
|
45
|
+
"perm": "android.permission.REQUEST_INSTALL_PACKAGES",
|
|
46
|
+
"form": "Install unknown apps declaration",
|
|
47
|
+
"msg": "REQUEST_INSTALL_PACKAGES has its own Play declaration: say which files you install and why, or the update is rejected as unapproved app distribution."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "photo_video_broad",
|
|
51
|
+
"sev": "error",
|
|
52
|
+
"kind": "perm_any",
|
|
53
|
+
"perms": [
|
|
54
|
+
"android.permission.READ_MEDIA_IMAGES",
|
|
55
|
+
"android.permission.READ_MEDIA_VIDEO"
|
|
56
|
+
],
|
|
57
|
+
"form": "Photo and video permissions declaration",
|
|
58
|
+
"msg": "Broad photo and video access needs the Photo and video permissions declaration: Play expects the Android photo picker for one-off or infrequent access, and broad access only where it is core functionality."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "health_data",
|
|
62
|
+
"sev": "error",
|
|
63
|
+
"kind": "perm_prefix",
|
|
64
|
+
"prefix": "android.permission.health.",
|
|
65
|
+
"form": "Health apps declaration (Health Connect)",
|
|
66
|
+
"msg": "Health Connect read or write permissions need the Health apps declaration form approved before release, plus a privacy policy that names the health data you read."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "full_screen_intent",
|
|
70
|
+
"sev": "warn",
|
|
71
|
+
"kind": "perm",
|
|
72
|
+
"perm": "android.permission.USE_FULL_SCREEN_INTENT",
|
|
73
|
+
"form": "Full-screen intent permission declaration",
|
|
74
|
+
"msg": "From Android 14 USE_FULL_SCREEN_INTENT is granted at install only to calling and alarm apps; everything else must justify it to Play and fall back gracefully when the user has turned it off."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "use_exact_alarm",
|
|
78
|
+
"sev": "error",
|
|
79
|
+
"kind": "perm",
|
|
80
|
+
"perm": "android.permission.USE_EXACT_ALARM",
|
|
81
|
+
"form": "Exact alarm permission declaration",
|
|
82
|
+
"msg": "USE_EXACT_ALARM is restricted to alarm clock and calendar apps; if that is not your app Play expects SCHEDULE_EXACT_ALARM with a user-facing setting, or WorkManager."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "schedule_exact_alarm",
|
|
86
|
+
"sev": "warn",
|
|
87
|
+
"kind": "perm",
|
|
88
|
+
"perm": "android.permission.SCHEDULE_EXACT_ALARM",
|
|
89
|
+
"form": "Exact alarm use case (Play policy answer)",
|
|
90
|
+
"msg": "SCHEDULE_EXACT_ALARM is not granted by default on Android 13 and later: check canScheduleExactAlarms() and send the user to the Alarms and reminders setting, and keep the use case ready for review."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "ad_id",
|
|
94
|
+
"sev": "warn",
|
|
95
|
+
"kind": "perm",
|
|
96
|
+
"perm": "com.google.android.gms.permission.AD_ID",
|
|
97
|
+
"form": "Data safety advertising ID disclosure",
|
|
98
|
+
"msg": "Declaring AD_ID means the Data safety form must disclose that the app collects the advertising ID and for what purpose; the two answers are cross-checked at review."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"id": "accessibility_service",
|
|
102
|
+
"sev": "error",
|
|
103
|
+
"kind": "service_perm",
|
|
104
|
+
"perm": "android.permission.BIND_ACCESSIBILITY_SERVICE",
|
|
105
|
+
"form": "Accessibility API prominent disclosure",
|
|
106
|
+
"msg": "An accessibility service is bound here: Play requires a prominent in-app disclosure of what the service reads and does, and rejects accessibility use that is not an accessibility feature."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"id": "fgs_use_case",
|
|
110
|
+
"sev": "warn",
|
|
111
|
+
"kind": "fgs_any",
|
|
112
|
+
"form": "Foreground service use case declaration",
|
|
113
|
+
"msg": "Each foregroundServiceType in the manifest needs a matching use case in the Play Console foreground service declaration, with a video showing the service running."
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"id": "fgs_permission_missing",
|
|
117
|
+
"sev": "error",
|
|
118
|
+
"kind": "fgs_permission",
|
|
119
|
+
"form": "No form - Android 14 runtime requirement",
|
|
120
|
+
"msg": "Android 14 (API 34) throws SecurityException when a foreground service starts with a type whose FOREGROUND_SERVICE_* permission is missing from the manifest."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "special_use_subtype",
|
|
124
|
+
"sev": "error",
|
|
125
|
+
"kind": "special_use",
|
|
126
|
+
"form": "Foreground service declaration (specialUse subtype)",
|
|
127
|
+
"msg": "foregroundServiceType=\"specialUse\" needs a <property android:name=\"android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE\"> inside the same <service>, and Play reviews that subtype string by hand."
|
|
128
|
+
}
|
|
129
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Play Declaration Gap Lint (AndroidManifest)",
|
|
3
|
+
"subtitle": "15 checks that name every Play Console declaration your manifest opens",
|
|
4
|
+
"bin": "play-declaration-gap-lint",
|
|
5
|
+
"price": 29,
|
|
6
|
+
"free": "Check the AndroidManifest.xml open in your editor: all 15 checks, one line number each, with the Play Console surface named - no key, no limit",
|
|
7
|
+
"paid": "Sweep every manifest in the workspace, library and SDK modules included, and write a dated playDecl-report.md declaration worksheet you keep in the repository",
|
|
8
|
+
"need_key": "This option needs a licence (Play Declaration Gap Lint (AndroidManifest)).",
|
|
9
|
+
"exts": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: readystack-play-declaration-gap-lint
|
|
3
|
+
Version: 1.0.4
|
|
4
|
+
Summary: 15 checks that name every Play Console declaration your manifest opens (bundled Node.js runtime)
|
|
5
|
+
Author-email: ReadyStack <support@getreadystack.com>
|
|
6
|
+
License: Proprietary - see LICENSE.txt
|
|
7
|
+
Project-URL: Homepage, https://getreadystack.com/tools/play-declaration-gap-lint?ref=pypi
|
|
8
|
+
Project-URL: Documentation, https://getreadystack.com/tools/play-declaration-gap-lint?ref=pypi
|
|
9
|
+
Project-URL: Support, https://getreadystack.com/support
|
|
10
|
+
Keywords: androidmanifest,google play,play console,permissions,declaration,foreground service,android lint
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE.txt
|
|
18
|
+
Requires-Dist: nodejs-wheel-binaries>=20
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Play Declaration Gap Lint (AndroidManifest)
|
|
22
|
+
|
|
23
|
+
> Runs the same checker as the npm package `@readystack/play-declaration-gap-lint` on a Node.js runtime that pip installs for you (`nodejs-wheel-binaries`) - no system Node.js needed. Your files are checked locally.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
15 checks that name every Play Console declaration your manifest opens
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
play-declaration-gap-lint file
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Node 18+. The same 15 rules as the VS Code extension, from a terminal or CI.
|
|
36
|
+
|
|
37
|
+
## Free
|
|
38
|
+
|
|
39
|
+
- Check the AndroidManifest.xml open in your editor: all 15 checks, one line number each, with the Play Console surface named - no key, no limit
|
|
40
|
+
- `--rules` lists every rule
|
|
41
|
+
|
|
42
|
+
## With a licence ($29 once)
|
|
43
|
+
|
|
44
|
+
- Sweep every manifest in the workspace, library and SDK modules included, and write a dated playDecl-report.md declaration worksheet you keep in the repository
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
@readystack/play-declaration-gap-lint --dir ./templates --report html --out report.html
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A freelance Android release engineer bills $60-$120 an hour; reading a multi-module app's manifests against every current Play declaration is most of a working day
|
|
51
|
+
|
|
52
|
+
## Use from an AI agent (MCP)
|
|
53
|
+
|
|
54
|
+
Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "mcpServers": { "play-declaration-gap-lint": { "command": "npx", "args": ["-y", "@readystack/play-declaration-gap-lint", "--mcp"] } } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Tools: `check_text` and `check_file` (free) · `check_dir` (licence). The agent gets every finding with the line number.
|
|
61
|
+
|
|
62
|
+
## Use in CI
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- name: Play Declaration Gap Lint (AndroidManifest)
|
|
66
|
+
run: npx -y @readystack/play-declaration-gap-lint --dir . --ci
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
(container: `docker run --rm -v "$PWD:/work" getreadystack/play-declaration-gap-lint --dir /work --ci`)
|
|
70
|
+
|
|
71
|
+
The folder sweep, reports and CI mode need one licence — one payment, no subscription. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
|
|
72
|
+
|
|
73
|
+
[Get a licence](https://getreadystack.com/api/buy/cl/polar_cl_9pjxdAKOI372jyFZtBN3s5u8ajfGhvBhh1rwf3TGsGE)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
<!-- play declaration gap lint -->
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## Install (PyPI)
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
pip install readystack-play-declaration-gap-lint
|
|
83
|
+
play-declaration-gap-lint --help
|
|
84
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
readystack_play_declaration_gap_lint/__init__.py,sha256=thGZHY6pLPhJc6CwYBjUaQ6pGxLhwPMp_pjoYsiwvI4,22
|
|
2
|
+
readystack_play_declaration_gap_lint/__main__.py,sha256=3DzZxi-7VBAiEeEUw4wbFJcFLoWDkdhCPmwyaTT-DkY,359
|
|
3
|
+
readystack_play_declaration_gap_lint/js/cli.js,sha256=ib60vyNQbZ7meRCc-ACffzcJ04JFn1nbF-HjqGMWM_U,14665
|
|
4
|
+
readystack_play_declaration_gap_lint/js/engine.js,sha256=f7H-M7gNKGNfa0xmGPcShV6Yl9bmoMkAj33FVlskdJk,5295
|
|
5
|
+
readystack_play_declaration_gap_lint/js/license.js,sha256=_PA33pU_XH6hNIZd4ISNgEfb7tUVLxukYQknHstG34U,7565
|
|
6
|
+
readystack_play_declaration_gap_lint/js/package.json,sha256=nYUWQPV1UZNbRHox4xTpDOvCb1eUHIbkW9HR5ehq3bk,1025
|
|
7
|
+
readystack_play_declaration_gap_lint/js/rules.json,sha256=9PVRGVxupmt81q8__79_zzGeIZk19R0IND5u4ci7H3c,5734
|
|
8
|
+
readystack_play_declaration_gap_lint/js/strings.json,sha256=49n-16eukAs6iIky-byBi9B4AD1NDhPCAWZ3Q4bNmZc,627
|
|
9
|
+
readystack_play_declaration_gap_lint-1.0.4.dist-info/licenses/LICENSE.txt,sha256=9uIzpyW_98yr7kw-FbhU1Kv_Q03n82VE4c3cCxsJ_Xw,949
|
|
10
|
+
readystack_play_declaration_gap_lint-1.0.4.dist-info/METADATA,sha256=FQkNurUuTpCgj1z8OUWtnGqeeqsSLoig8Q7Vi1q9mUc,3254
|
|
11
|
+
readystack_play_declaration_gap_lint-1.0.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
readystack_play_declaration_gap_lint-1.0.4.dist-info/entry_points.txt,sha256=VidJkn9iyxPo1c3cCyMbgSKuiLLQA_SxFC-rcdR4X3I,97
|
|
13
|
+
readystack_play_declaration_gap_lint-1.0.4.dist-info/top_level.txt,sha256=0b0dJMDZDyqdikDCmTTiJAa-VZL4J4BnWifB9NpwdOU,37
|
|
14
|
+
readystack_play_declaration_gap_lint-1.0.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Play Declaration Gap Lint (AndroidManifest) — Licence
|
|
2
|
+
|
|
3
|
+
Free scope
|
|
4
|
+
Check the AndroidManifest.xml open in your editor and name every Play Console declaration it triggers, line by line is free for any use, personal or commercial, for as long as you keep the
|
|
5
|
+
extension installed, and the online check at the product page is free without registration.
|
|
6
|
+
|
|
7
|
+
Paid scope
|
|
8
|
+
Sweep every manifest in the workspace (app plus library and SDK modules) and write a dated declaration worksheet you keep and hand to review is the paid part. It asks for a licence key issued at purchase (one seat, one key). The key is checked against the payment provider and
|
|
9
|
+
cached locally for 30 days so it keeps working offline.
|
|
10
|
+
|
|
11
|
+
Redistribution
|
|
12
|
+
You may not resell, re-host or bundle this extension or its rule set. The rule set and the
|
|
13
|
+
engine are provided as-is; check results are advisory and do not constitute legal advice.
|
|
14
|
+
|
|
15
|
+
(c) ReadyStack — https://getreadystack.com
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
readystack_play_declaration_gap_lint
|