@readystack/gitlab-ci-break-lint 1.0.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/LICENSE.txt +16 -0
- package/README.md +54 -0
- package/cli.js +121 -0
- package/engine.js +193 -0
- package/license.js +83 -0
- package/package.json +41 -0
- package/rules.json +98 -0
- package/strings.json +10 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
GitLab CI Break Lint — Licence
|
|
2
|
+
|
|
3
|
+
Free scope
|
|
4
|
+
Lint the .gitlab-ci.yml you have open and name every retired keyword, invalid job wiring and GitHub Actions leftover, with the replacement line for each. 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
|
+
Scan every pipeline file in the repository in one pass and export a dated Markdown upgrade report you can attach to the change ticket. is the paid part. It asks for a licence key issued at purchase (one seat, one key;
|
|
9
|
+
7-day full refund, no questions). The key is checked against the payment provider and
|
|
10
|
+
cached locally for 30 days so it keeps working offline.
|
|
11
|
+
|
|
12
|
+
Redistribution
|
|
13
|
+
You may not resell, re-host or bundle this extension or its rule set. The rule set and the
|
|
14
|
+
engine are provided as-is; check results are advisory and do not constitute legal advice.
|
|
15
|
+
|
|
16
|
+
(c) ReadyStack — https://getreadystack.com
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# GitLab CI Break Lint
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Names the .gitlab-ci.yml lines your runner will reject after the next GitLab major upgrade
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx @readystack/gitlab-ci-break-lint file
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node 18+. The same 16 rules as the VS Code extension, from a terminal or CI.
|
|
14
|
+
|
|
15
|
+
## Free
|
|
16
|
+
|
|
17
|
+
- Lint the pipeline file you have open and get every retired keyword, GitHub Actions leftover and invalid job wiring with its line number and its replacement line.
|
|
18
|
+
- `--rules` lists every rule
|
|
19
|
+
|
|
20
|
+
## With a licence ($29 once)
|
|
21
|
+
|
|
22
|
+
- Scan every pipeline file in the repository in one pass and export a dated Markdown upgrade report (file, line, keyword, replacement) to attach to the change ticket.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
@readystack/gitlab-ci-break-lint --dir ./templates --report html --out report.html
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A freelance DevOps engineer's hour runs about $100, and reading a repository's pipelines by hand against a removals list is most of a morning.
|
|
29
|
+
|
|
30
|
+
## Use from an AI agent (MCP)
|
|
31
|
+
|
|
32
|
+
Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{ "mcpServers": { "gitlab-ci-break-lint": { "command": "npx", "args": ["-y", "@readystack/gitlab-ci-break-lint", "--mcp"] } } }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Tools: `check_text` and `check_file` (free) · `check_dir` (licence; the full sweep is free for 7 days). The agent gets every finding with the line number.
|
|
39
|
+
|
|
40
|
+
## Use in CI
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
- name: GitLab CI Break Lint
|
|
44
|
+
run: npx -y @readystack/gitlab-ci-break-lint --dir . --ci
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
(container: `docker run --rm -v "$PWD:/work" getreadystack/gitlab-ci-break-lint --dir /work --ci`)
|
|
48
|
+
|
|
49
|
+
Try the full run free for 7 days — no key needed. Then one licence, 7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
|
|
50
|
+
|
|
51
|
+
[Get a licence](https://buy.polar.sh/polar_cl_d7bGnhvRyjV580fnsaQ89C7TIU15QgC3kcWpO0NZLGc)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
<!-- gitlab ci break lint -->
|
package/cli.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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 (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
|
|
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
|
+
if (process.env.READYSTACK_NO_TRIAL) return { active: false };
|
|
43
|
+
try {
|
|
44
|
+
const p = path.join(path.dirname(lic.storePath()), S.bin + '.trial.json');
|
|
45
|
+
let t = null; try { t = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) {}
|
|
46
|
+
if (!t || !t.until) { t = { until: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString().slice(0, 10) }; fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(t)); }
|
|
47
|
+
return { active: new Date(t.until + 'T23:59:59Z').getTime() > Date.now(), until: t.until };
|
|
48
|
+
} catch (e) { return { active: false }; }
|
|
49
|
+
}
|
|
50
|
+
function mcpServe() {
|
|
51
|
+
process.env.READYSTACK_MCP = '1'; // s152 - 에이전트(Claude Code·Cursor)가 부른 세션은 사람 자리다 · 키 판 핑 src=mcp
|
|
52
|
+
// s144 — MCP server over stdio (newline-delimited JSON-RPC · no dependencies). Free: check_text · check_file. Licence (7-day trial): check_dir.
|
|
53
|
+
const rl = require('readline').createInterface({ input: process.stdin });
|
|
54
|
+
const send = (o) => process.stdout.write(JSON.stringify(o) + '\n');
|
|
55
|
+
const tools = [
|
|
56
|
+
{ 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'] } },
|
|
57
|
+
{ name: 'check_file', description: S.name + ' - run all checks on one file by path (free)', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
|
|
58
|
+
{ name: 'check_dir', description: S.name + ' - sweep a folder and return every finding (licence; the full run is free for 7 days)', inputSchema: { type: 'object', properties: { dir: { type: 'string' }, ext: { type: 'string', description: 'optional extension filter, e.g. .html' } }, required: ['dir'] } }
|
|
59
|
+
];
|
|
60
|
+
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 } } });
|
|
61
|
+
const fail = (id, text) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
|
|
62
|
+
rl.on('line', async (line) => {
|
|
63
|
+
let m; try { m = JSON.parse(line); } catch (e) { return; }
|
|
64
|
+
const id = m.id, method = m.method;
|
|
65
|
+
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' } } });
|
|
66
|
+
if (method === 'notifications/initialized' || method === 'ping') { if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} }); return; }
|
|
67
|
+
if (method === 'tools/list') return send({ jsonrpc: '2.0', id, result: { tools } });
|
|
68
|
+
if (method === 'tools/call') {
|
|
69
|
+
const name = (m.params || {}).name, args = (m.params || {}).arguments || {};
|
|
70
|
+
try {
|
|
71
|
+
if (name === 'check_text') return result(id, [{ file: args.path || '(text)', hits: scan(String(args.text || ''), args.path || '') }]);
|
|
72
|
+
if (name === 'check_file') return result(id, [{ file: args.path, hits: scan(fs.readFileSync(args.path, 'utf8'), args.path) }]);
|
|
73
|
+
if (name === 'check_dir') {
|
|
74
|
+
const r = await lic.ensure();
|
|
75
|
+
if (!r.ok && !trialState().active) return fail(id, S.need_key + ' Get a licence ($' + S.price + ', once, 7-day refund): ' + lic.BUY_URL);
|
|
76
|
+
const files = walk(args.dir, args.ext ? [args.ext] : (S.exts || []), []);
|
|
77
|
+
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) }; }));
|
|
78
|
+
}
|
|
79
|
+
return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool ' + name } });
|
|
80
|
+
} catch (e) { return fail(id, String(e && e.message || e)); }
|
|
81
|
+
}
|
|
82
|
+
if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function help() {
|
|
86
|
+
return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
|
|
87
|
+
' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
|
|
88
|
+
' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
|
|
89
|
+
' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
|
|
90
|
+
' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
|
|
91
|
+
' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
|
|
92
|
+
' ' + S.bin + ' --mcp run as an MCP server (stdio) for Claude Code / Cursor / Windsurf - free checks, folder sweep needs a licence', '',
|
|
93
|
+
'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
|
|
94
|
+
}
|
|
95
|
+
(async function main() {
|
|
96
|
+
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 구독 피드 병합 (키 있는 손님만)
|
|
97
|
+
const a = process.argv.slice(2);
|
|
98
|
+
const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
|
|
99
|
+
if (a.includes('--mcp')) { mcpServe(); return; }
|
|
100
|
+
if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
|
|
101
|
+
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; }
|
|
102
|
+
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); }
|
|
103
|
+
const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
|
|
104
|
+
const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
|
|
105
|
+
const paid = !!(dir || fmt || ci);
|
|
106
|
+
if (paid) {
|
|
107
|
+
const r = await lic.ensure();
|
|
108
|
+
if (!r.ok) {
|
|
109
|
+
const t = trialState(); // s144 reverse trial: the full run is free for 7 days from the first paid use, then the key
|
|
110
|
+
if (t.active) process.stderr.write('Trial: the full run is free until ' + t.until + ' — after that $' + S.price + ' once (7-day refund). Get a licence: ' + lic.BUY_URL + '\n');
|
|
111
|
+
else { process.stderr.write(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); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
|
|
115
|
+
if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
|
|
116
|
+
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) }; });
|
|
117
|
+
const text = render(rows, fmt || 'text');
|
|
118
|
+
if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
119
|
+
const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
|
|
120
|
+
if (ci && errors) process.exit(1);
|
|
121
|
+
})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
|
package/engine.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* GitLab CI Break Lint - engine. Same file runs in Node (extension) and in the browser (web page). */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
var RULES = (typeof module !== 'undefined' && module.exports)
|
|
6
|
+
? require('./rules.json')
|
|
7
|
+
: window.GLCI_RULES;
|
|
8
|
+
|
|
9
|
+
var BY_ID = {};
|
|
10
|
+
for (var r = 0; r < RULES.length; r++) BY_ID[RULES[r].id] = RULES[r];
|
|
11
|
+
|
|
12
|
+
var RESERVED = {
|
|
13
|
+
stages: 1, types: 1, variables: 1, include: 1, default: 1, workflow: 1,
|
|
14
|
+
image: 1, services: 1, before_script: 1, after_script: 1, script: 1,
|
|
15
|
+
cache: 1, on: 1, jobs: 1, run: 1, name: 1
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function strip(line) {
|
|
19
|
+
var m = /^(\s*)([\s\S]*)$/.exec(line);
|
|
20
|
+
var body = m[2].replace(/(^|\s)#.*$/, '$1');
|
|
21
|
+
return m[1] + body.replace(/\s+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function indentOf(line) { return /^(\s*)/.exec(line)[1].length; }
|
|
25
|
+
|
|
26
|
+
function unquote(v) {
|
|
27
|
+
return String(v).trim().replace(/^['"]|['"]$/g, '').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parse(text) {
|
|
31
|
+
var lines = String(text == null ? '' : text).split(/\r?\n/).map(strip);
|
|
32
|
+
var tops = [];
|
|
33
|
+
for (var i = 0; i < lines.length; i++) {
|
|
34
|
+
var l = lines[i];
|
|
35
|
+
if (!l.trim() || indentOf(l) !== 0) continue;
|
|
36
|
+
var m = /^([A-Za-z_.][A-Za-z0-9_.\-]*):(\s[\s\S]*)?$/.exec(l);
|
|
37
|
+
if (m) tops.push({ name: m[1], inline: (m[2] || '').trim(), start: i });
|
|
38
|
+
}
|
|
39
|
+
for (var j = 0; j < tops.length; j++) {
|
|
40
|
+
tops[j].end = (j + 1 < tops.length) ? tops[j + 1].start : lines.length;
|
|
41
|
+
}
|
|
42
|
+
return { lines: lines, tops: tops };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function listUnder(lines, start, end, baseIndent) {
|
|
46
|
+
var out = [];
|
|
47
|
+
for (var i = start; i < end; i++) {
|
|
48
|
+
if (!lines[i].trim()) continue;
|
|
49
|
+
if (indentOf(lines[i]) <= baseIndent) break;
|
|
50
|
+
var m = /^\s*-\s*(.+)$/.exec(lines[i]);
|
|
51
|
+
if (!m) break;
|
|
52
|
+
out.push({ value: unquote(m[1]), line: i });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inlineList(raw) {
|
|
58
|
+
var m = /^\[([\s\S]*)\]$/.exec(raw.trim());
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
return m[1].split(',').map(unquote).filter(function (s) { return s; });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function check(text, opts) {
|
|
64
|
+
opts = opts || {};
|
|
65
|
+
var p = parse(text);
|
|
66
|
+
var lines = p.lines, tops = p.tops;
|
|
67
|
+
var findings = [];
|
|
68
|
+
var seen = {};
|
|
69
|
+
|
|
70
|
+
function add(id, line, extra) {
|
|
71
|
+
var rule = BY_ID[id] || { sev: 'error', title: id, fix: '' };
|
|
72
|
+
var key = id + '@' + line;
|
|
73
|
+
if (seen[key]) return;
|
|
74
|
+
seen[key] = 1;
|
|
75
|
+
findings.push({
|
|
76
|
+
check: id,
|
|
77
|
+
sev: rule.sev,
|
|
78
|
+
msg: rule.title + (extra ? ' (' + extra + ')' : '') + ' - ' + rule.fix,
|
|
79
|
+
line: line + 1
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ---- stages declared at the top of the file ---- */
|
|
84
|
+
var stages = [];
|
|
85
|
+
var definedKeys = {};
|
|
86
|
+
for (var t = 0; t < tops.length; t++) {
|
|
87
|
+
definedKeys[tops[t].name] = 1;
|
|
88
|
+
if (tops[t].name === 'stages') {
|
|
89
|
+
var inl = inlineList(tops[t].inline);
|
|
90
|
+
if (inl) { for (var q = 0; q < inl.length; q++) stages.push(inl[q]); }
|
|
91
|
+
else {
|
|
92
|
+
var items = listUnder(lines, tops[t].start + 1, tops[t].end, 0);
|
|
93
|
+
for (var s = 0; s < items.length; s++) stages.push(items[s].value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (tops[t].name === 'on' || tops[t].name === 'jobs') {
|
|
97
|
+
add('github_top_level_key', tops[t].start, tops[t].name + ':');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ---- line scans ---- */
|
|
102
|
+
for (var i = 0; i < lines.length; i++) {
|
|
103
|
+
var l = lines[i];
|
|
104
|
+
if (!l.trim()) continue;
|
|
105
|
+
var mv = /\bCI_BUILD_[A-Z0-9_]+/.exec(l);
|
|
106
|
+
if (mv) add('removed_ci_build_vars', i, mv[0]);
|
|
107
|
+
var mj = /\bCI_JOB_JWT(_V[12])?\b/.exec(l);
|
|
108
|
+
if (mj) add('removed_ci_job_jwt', i, mj[0]);
|
|
109
|
+
if (/\$\{\{/.test(l)) add('github_expression', i, null);
|
|
110
|
+
if (/^\s*runs-on\s*:/.test(l)) add('github_runs_on', i, null);
|
|
111
|
+
if (/^\s*(steps\s*:|-\s*uses\s*:|uses\s*:)/.test(l)) add('github_uses_steps', i, null);
|
|
112
|
+
if (/^\s*cobertura\s*:/.test(l)) add('removed_cobertura_report', i, null);
|
|
113
|
+
if (indentOf(l) === 0 && /^types\s*:/.test(l)) add('removed_type_keyword', i, 'types:');
|
|
114
|
+
if (indentOf(l) > 0 && /^\s+type\s*:\s*\S/.test(l)) add('removed_type_keyword', i, 'type:');
|
|
115
|
+
if (indentOf(l) > 0 && /^\s+(only|except)\s*:/.test(l)) add('retired_only_except', i, null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ---- per job ---- */
|
|
119
|
+
for (var k = 0; k < tops.length; k++) {
|
|
120
|
+
var job = tops[k];
|
|
121
|
+
if (RESERVED[job.name] || job.inline) continue;
|
|
122
|
+
var hidden = job.name.charAt(0) === '.';
|
|
123
|
+
var hasScript = false, hasOnly = false, hasRules = false;
|
|
124
|
+
var startIn = -1, whenDelayed = false;
|
|
125
|
+
|
|
126
|
+
for (var n = job.start + 1; n < job.end; n++) {
|
|
127
|
+
var jl = lines[n];
|
|
128
|
+
if (!jl.trim()) continue;
|
|
129
|
+
|
|
130
|
+
if (/^\s+(script|trigger|extends|run)\s*:/.test(jl)) hasScript = true;
|
|
131
|
+
if (/^\s+(only|except)\s*:/.test(jl)) hasOnly = true;
|
|
132
|
+
if (/^\s+rules\s*:/.test(jl)) hasRules = true;
|
|
133
|
+
if (/^\s+start_in\s*:/.test(jl)) startIn = n;
|
|
134
|
+
if (/^\s+when\s*:\s*delayed\b/.test(jl)) whenDelayed = true;
|
|
135
|
+
|
|
136
|
+
var ms = /^\s+stage\s*:\s*(\S.*)$/.exec(jl);
|
|
137
|
+
if (ms && stages.length) {
|
|
138
|
+
var st = unquote(ms[1]);
|
|
139
|
+
if (st !== '.pre' && st !== '.post' && stages.indexOf(st) < 0) {
|
|
140
|
+
add('stage_not_declared', n, st);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var mp = /^\s+parallel\s*:\s*(\d+)\s*$/.exec(jl);
|
|
145
|
+
if (mp) {
|
|
146
|
+
var num = parseInt(mp[1], 10);
|
|
147
|
+
if (num < 2 || num > 200) add('parallel_out_of_range', n, String(num));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
var me = /^\s+extends\s*:\s*(\S.*)$/.exec(jl);
|
|
151
|
+
if (me) {
|
|
152
|
+
var ex = inlineList(me[1]) || [unquote(me[1])];
|
|
153
|
+
for (var e = 0; e < ex.length; e++) {
|
|
154
|
+
if (ex[e] && !definedKeys[ex[e]]) add('extends_undefined_key', n, ex[e]);
|
|
155
|
+
}
|
|
156
|
+
} else if (/^\s+extends\s*:\s*$/.test(jl)) {
|
|
157
|
+
var exl = listUnder(lines, n + 1, job.end, indentOf(jl));
|
|
158
|
+
for (var e2 = 0; e2 < exl.length; e2++) {
|
|
159
|
+
if (!definedKeys[exl[e2].value]) add('extends_undefined_key', exl[e2].line, exl[e2].value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
var mn = /^\s+needs\s*:\s*(\S.*)?$/.exec(jl);
|
|
164
|
+
if (mn) {
|
|
165
|
+
var needs = [];
|
|
166
|
+
if (mn[1]) {
|
|
167
|
+
var il = inlineList(mn[1]);
|
|
168
|
+
if (il) { for (var y = 0; y < il.length; y++) needs.push({ value: il[y], line: n }); }
|
|
169
|
+
} else {
|
|
170
|
+
needs = listUnder(lines, n + 1, job.end, indentOf(jl));
|
|
171
|
+
}
|
|
172
|
+
for (var d = 0; d < needs.length; d++) {
|
|
173
|
+
var nm = needs[d].value.replace(/^job\s*:\s*/, '');
|
|
174
|
+
nm = unquote(nm.split(/\s*,\s*/)[0]);
|
|
175
|
+
if (!nm || /[:{]/.test(nm)) continue;
|
|
176
|
+
if (!definedKeys[nm]) add('needs_undefined_job', needs[d].line, nm);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (hasOnly && hasRules) add('only_and_rules_conflict', job.start, job.name);
|
|
182
|
+
if (startIn >= 0 && !whenDelayed) add('start_in_without_delayed', startIn, job.name);
|
|
183
|
+
if (!hidden && !hasScript) add('job_without_script', job.start, job.name);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
findings.sort(function (a, b) { return a.line - b.line; });
|
|
187
|
+
return { findings: findings, today: opts.today || '' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
var api = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length };
|
|
191
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
192
|
+
if (typeof window !== 'undefined') window.GLCIENGINE = api;
|
|
193
|
+
})();
|
package/license.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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 = '32c30dc9-ac08-4afd-b581-b3d482cadf27'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
|
|
6
|
+
const BUY_URL = 'https://buy.polar.sh/polar_cl_d7bGnhvRyjV580fnsaQ89C7TIU15QgC3kcWpO0NZLGc';
|
|
7
|
+
const SLUG = 'gitlab-ci-break-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
|
+
function validate(key) {
|
|
15
|
+
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); });
|
|
16
|
+
}
|
|
17
|
+
function validate1(key, ben) {
|
|
18
|
+
return new Promise(function (resolve) {
|
|
19
|
+
if (!ORG_ID) return resolve({ ok: false, offline: false });
|
|
20
|
+
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 });
|
|
21
|
+
const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
|
|
22
|
+
headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
|
|
23
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
24
|
+
res.on('end', function () {
|
|
25
|
+
if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
|
|
26
|
+
try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
|
|
27
|
+
catch (e) { resolve({ ok: false, offline: false }); }
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
|
|
31
|
+
req.on('error', function () { resolve({ ok: false, offline: true }); });
|
|
32
|
+
req.write(body); req.end();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// ★s151 2026-09-19 — 키 판(키가 없거나 거절된 순간)을 익명으로 센다 (슬러그·출처·이유만) · DO_NOT_TRACK=1 · READYSTACK_NO_TELEMETRY 면 안 보낸다 · 실패는 조용히 · 프로세스당 한 번.
|
|
36
|
+
let _pinged = false;
|
|
37
|
+
function pingPaywall(why) {
|
|
38
|
+
try {
|
|
39
|
+
if (_pinged) return; _pinged = true;
|
|
40
|
+
if (process.env.DO_NOT_TRACK === '1' || process.env.READYSTACK_NO_TELEMETRY || process.env.CI) return; // s151: CI(깃허브 액션 등)와 우리 빌드는 손님이 아니다
|
|
41
|
+
if (!(process.stdout.isTTY || process.stdin.isTTY || process.env.READYSTACK_MCP === '1')) return; // s152: 사람 자리(터미널·MCP 세션)에서만 센다 - 발행 1분 뒤 남의 실행기(JP · 우리 3대는 US)가 돌린 no_key 13건은 손님이 아니다
|
|
42
|
+
const body = JSON.stringify({ t: 'paywall', slug: SLUG, src: process.env.READYSTACK_MCP === '1' ? 'mcp' : 'cli', why: why || 'no_key' });
|
|
43
|
+
const req = https.request({ hostname: 'getreadystack.com', path: '/api/ev', method: 'POST', timeout: 3000,
|
|
44
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'user-agent': 'readystack-cli/' + SLUG } }, function (res) { res.resume(); });
|
|
45
|
+
req.on('timeout', function () { req.destroy(); }); req.on('error', function () {});
|
|
46
|
+
req.write(body); req.end();
|
|
47
|
+
} catch (e) { /* 세는 것이 실패해도 상품은 돈다 */ }
|
|
48
|
+
}
|
|
49
|
+
async function ensure(explicitKey) {
|
|
50
|
+
const st = load();
|
|
51
|
+
const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
|
|
52
|
+
if (!key) { pingPaywall('no_key'); return { ok: false, why: 'no_key' }; } // s151
|
|
53
|
+
const age = Date.now() - (st.okAt || 0);
|
|
54
|
+
if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
|
|
55
|
+
const r = await validate(String(key).trim());
|
|
56
|
+
if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
|
|
57
|
+
if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
|
|
58
|
+
if (!r.offline) pingPaywall('invalid'); // s151
|
|
59
|
+
return { ok: false, why: r.offline ? 'offline' : 'invalid' };
|
|
60
|
+
}
|
|
61
|
+
// ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
|
|
62
|
+
const FEED_URL = 'https://getreadystack.com/api/rules/';
|
|
63
|
+
function pullFeed() {
|
|
64
|
+
const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
|
|
65
|
+
if (!key) return Promise.resolve(cached);
|
|
66
|
+
if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
|
|
67
|
+
return new Promise(function (resolve) {
|
|
68
|
+
let req;
|
|
69
|
+
try {
|
|
70
|
+
req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
|
|
71
|
+
let buf = ''; res.on('data', function (d) { buf += d; });
|
|
72
|
+
res.on('end', function () {
|
|
73
|
+
if (res.statusCode !== 200) return resolve(cached);
|
|
74
|
+
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); }
|
|
75
|
+
catch (e) { resolve(cached); }
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
} catch (e) { return resolve(cached); }
|
|
79
|
+
req.on('timeout', function () { req.destroy(); resolve(cached); });
|
|
80
|
+
req.on('error', function () { resolve(cached); });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
module.exports = { ensure, BUY_URL, storePath, pullFeed };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@readystack/gitlab-ci-break-lint",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Names the .gitlab-ci.yml lines your runner will reject after the next GitLab major upgrade",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"gitlab",
|
|
11
|
+
"gitlab-ci",
|
|
12
|
+
"pipeline",
|
|
13
|
+
"yaml",
|
|
14
|
+
"lint",
|
|
15
|
+
"devops",
|
|
16
|
+
"ci-cd"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://getreadystack.com",
|
|
19
|
+
"funding": "https://buy.polar.sh/polar_cl_d7bGnhvRyjV580fnsaQ89C7TIU15QgC3kcWpO0NZLGc",
|
|
20
|
+
"bin": {
|
|
21
|
+
"gitlab-ci-break-lint": "cli.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"mcpName": "io.github.jmshinhwa/gitlab-ci-break-lint",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/jmshinhwa/readystack-themes.git",
|
|
30
|
+
"directory": "gitlab-ci-break-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
|
+
}
|
package/rules.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "removed_ci_build_vars",
|
|
4
|
+
"sev": "error",
|
|
5
|
+
"title": "CI_BUILD_* variable",
|
|
6
|
+
"fix": "Rename to the CI_JOB_* / CI_COMMIT_* equivalent (CI_BUILD_REF is CI_COMMIT_SHA, CI_BUILD_ID is CI_JOB_ID)."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "removed_type_keyword",
|
|
10
|
+
"sev": "error",
|
|
11
|
+
"title": "type: / types: keyword",
|
|
12
|
+
"fix": "Use stage: on a job and stages: at the top of the file."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "removed_cobertura_report",
|
|
16
|
+
"sev": "error",
|
|
17
|
+
"title": "artifacts:reports:cobertura",
|
|
18
|
+
"fix": "Use artifacts:reports:coverage_report with coverage_format: cobertura and path:."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "removed_ci_job_jwt",
|
|
22
|
+
"sev": "error",
|
|
23
|
+
"title": "CI_JOB_JWT token",
|
|
24
|
+
"fix": "Declare an id_tokens: block on the job and read that token instead."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "retired_only_except",
|
|
28
|
+
"sev": "warn",
|
|
29
|
+
"title": "only: / except: job filter",
|
|
30
|
+
"fix": "Move the condition into rules: with if:, changes: or exists:."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "only_and_rules_conflict",
|
|
34
|
+
"sev": "error",
|
|
35
|
+
"title": "rules: together with only:/except:",
|
|
36
|
+
"fix": "Keep rules: and delete only:/except: from the same job."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "github_runs_on",
|
|
40
|
+
"sev": "error",
|
|
41
|
+
"title": "runs-on: from GitHub Actions",
|
|
42
|
+
"fix": "GitLab picks runners by tags:. Replace runs-on: with tags: or drop it."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "github_uses_steps",
|
|
46
|
+
"sev": "error",
|
|
47
|
+
"title": "steps: / uses: from GitHub Actions",
|
|
48
|
+
"fix": "GitLab jobs run shell lines under script:. There is no uses: action."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "github_expression",
|
|
52
|
+
"sev": "error",
|
|
53
|
+
"title": "${{ }} expression from GitHub Actions",
|
|
54
|
+
"fix": "GitLab expands plain $VARIABLE. Rewrite the expression as a variable or a rules: if:."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "github_top_level_key",
|
|
58
|
+
"sev": "error",
|
|
59
|
+
"title": "top-level on: / jobs: from GitHub Actions",
|
|
60
|
+
"fix": "GitLab has no on: or jobs: mapping. Declare jobs at the top level and gate them with workflow:rules or rules:."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "stage_not_declared",
|
|
64
|
+
"sev": "error",
|
|
65
|
+
"title": "stage: missing from stages:",
|
|
66
|
+
"fix": "Add the stage name to the stages: list, or point the job at a declared stage."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "needs_undefined_job",
|
|
70
|
+
"sev": "error",
|
|
71
|
+
"title": "needs: points at an undefined job",
|
|
72
|
+
"fix": "Reference a job that exists in this file, or remove the needs: entry."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "extends_undefined_key",
|
|
76
|
+
"sev": "error",
|
|
77
|
+
"title": "extends: points at an undefined key",
|
|
78
|
+
"fix": "Define the hidden job (.name:) in this file or include the template that defines it."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "start_in_without_delayed",
|
|
82
|
+
"sev": "error",
|
|
83
|
+
"title": "start_in: without when: delayed",
|
|
84
|
+
"fix": "Add when: delayed to the same job, otherwise start_in: is rejected."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "parallel_out_of_range",
|
|
88
|
+
"sev": "error",
|
|
89
|
+
"title": "parallel: outside 2-200",
|
|
90
|
+
"fix": "Use a value between 2 and 200, or delete parallel: for a single job."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "job_without_script",
|
|
94
|
+
"sev": "error",
|
|
95
|
+
"title": "job with no script:",
|
|
96
|
+
"fix": "Give the job a script:, a trigger:, or an extends: that supplies one."
|
|
97
|
+
}
|
|
98
|
+
]
|
package/strings.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "GitLab CI Break Lint",
|
|
3
|
+
"subtitle": "Names the .gitlab-ci.yml lines your runner will reject after the next GitLab major upgrade",
|
|
4
|
+
"bin": "gitlab-ci-break-lint",
|
|
5
|
+
"price": 29,
|
|
6
|
+
"free": "Lint the pipeline file you have open and get every retired keyword, GitHub Actions leftover and invalid job wiring with its line number and its replacement line.",
|
|
7
|
+
"paid": "Scan every pipeline file in the repository in one pass and export a dated Markdown upgrade report (file, line, keyword, replacement) to attach to the change ticket.",
|
|
8
|
+
"need_key": "This option needs a licence (GitLab CI Break Lint).",
|
|
9
|
+
"exts": []
|
|
10
|
+
}
|