arkgate 3.7.0 → 3.8.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 +76 -1145
- package/README.md +59 -19
- package/bin/ark-check-runtime.mjs +1598 -0
- package/bin/ark-check.mjs +32 -1565
- package/bin/ark-mcp-runtime.mjs +1976 -0
- package/bin/ark-mcp.mjs +84 -1495
- package/bin/ark-shared.mjs +34 -38
- package/bin/ark.mjs +33 -66
- package/bin/lib/adapter-contract.mjs +161 -9
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/analysis-completeness.mjs +28 -0
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/analysis-policy.mjs +27 -0
- package/bin/lib/architecture-scan.mjs +70 -357
- package/bin/lib/auto-patch.mjs +76 -8
- package/bin/lib/ci-and-commands.mjs +1 -1
- package/bin/lib/codex-home.mjs +43 -16
- package/bin/lib/design-delta.mjs +4 -0
- package/bin/lib/doctor-advisories.mjs +4 -3
- package/bin/lib/doctor-plan.mjs +40 -41
- package/bin/lib/enforcement-state.mjs +2 -0
- package/bin/lib/github-enforcement.mjs +443 -0
- package/bin/lib/hook-templates.mjs +12 -148
- package/bin/lib/html-report-advisories.mjs +1 -1
- package/bin/lib/html-report-depth.mjs +9 -0
- package/bin/lib/html-report.mjs +5 -5
- package/bin/lib/install-migrate.mjs +83 -79
- package/bin/lib/managed-upgrade.mjs +622 -0
- package/bin/lib/mcp-adoption.mjs +3 -1
- package/bin/lib/parse-health.mjs +6 -5
- package/bin/lib/port-proof.mjs +2 -2
- package/bin/lib/prepare-change.mjs +68 -38
- package/bin/lib/prepare-write.mjs +7 -1
- package/bin/lib/resident-doctor-client.mjs +55 -0
- package/bin/lib/resident-hook.mjs +247 -0
- package/bin/lib/resolved-candidate-facts.mjs +1160 -0
- package/bin/lib/scan-files.mjs +19 -6
- package/bin/lib/snippet-analysis.mjs +119 -0
- package/bin/lib/source-policy.mjs +24 -0
- package/bin/lib/typescript-host.mjs +15 -18
- package/bin/lib/unavailable-analysis.mjs +76 -0
- package/bin/lib/upgrade-command.mjs +115 -0
- package/bin/lib/weakest-link.mjs +21 -179
- package/bin/lib/write-path-capabilities.mjs +167 -16
- package/bin/lib/write-path-detect.mjs +3 -2
- package/dist/eslint/index.cjs +3 -3
- package/dist/eslint/index.d.ts +3 -0
- package/dist/eslint/index.js +3 -3
- package/dist/index.cjs +7 -7
- package/dist/index.d.ts +1073 -141
- package/dist/index.js +7 -7
- package/docs/agent-guide.md +103 -59
- package/docs/ai-gates.md +97 -16
- package/docs/demos/01-write-gate-self-correction.md +2 -2
- package/docs/enthusiast/README.md +10 -10
- package/docs/enthusiast/how-to-gallery-starter.md +2 -2
- package/docs/enthusiast/reference-commands.md +18 -1
- package/docs/enthusiast/tutorial-first-project.md +2 -2
- package/docs/package-surface.md +98 -12
- package/docs/typescript-support.md +109 -37
- package/package.json +32 -4
- package/schemas/ark.analysis-result.schema.json +159 -2
- package/schemas/ark.design-delta.schema.json +1 -0
- package/schemas/ark.enforcement-state.schema.json +84 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
- package/server.json +2 -2
- package/templates/skills/ark-explore.md +5 -5
- package/templates/skills/ark-fix.md +1 -1
- package/templates/skills/ark-runtime.md +15 -8
- package/templates/skills/ark-upgrade.md +122 -182
- package/bin/lib/ai-velocity.mjs +0 -293
- package/bin/lib/graph-cycles.mjs +0 -6
- package/bin/lib/safety-diagnostics.mjs +0 -284
- package/bin/lib/ts-resolve.mjs +0 -228
- package/dist/configTypes-DAPvBqK6.d.cts +0 -61
- package/dist/eslint/index.d.cts +0 -146
- package/dist/index.d.cts +0 -986
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/** Exact local workflow evidence plus GitHub classic-protection/ruleset correlation. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const IF_LINE = /^[ \t]*(?:-\s+)?(?:"if"|'if'|if):\s*(.*?)\s*(?:#.*)?$/i;
|
|
7
|
+
const CONTINUE_LINE = /^[ \t]*(?:-\s+)?(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*(.*?)\s*(?:#.*)?$/i;
|
|
8
|
+
const SAFE_IF = /^(?:['"]?true['"]?|['"]?\$\{\{\s*(?:true|always\(\))\s*\}\}['"]?)$/i;
|
|
9
|
+
const SAFE_NEEDS_IF = /^['"]?\$\{\{\s*always\(\)\s*\}\}['"]?$/i;
|
|
10
|
+
const SAFE_CONTINUE = /^['"]?false['"]?$/i;
|
|
11
|
+
const ENV_PREFIX = /^(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)/;
|
|
12
|
+
const DIRECT_ARK = /^(?:(?:npx|yarn)\s+(?:arkgate-check|ark-check)(?=\s|$)|pnpm(?:\s+--\S+)*\s+exec\s+(?:arkgate-check|ark-check)(?=\s|$)|node\s+(?:\S+\/)?bin\/(?:arkgate-check|ark-check)\.mjs(?=\s|$)|(?:arkgate-check|ark-check)(?=\s|$))/;
|
|
13
|
+
const CHECK_SCRIPT = /^(?:(?:npm|pnpm)\s+run\s+check:architecture(?=\s|$)|yarn(?:\s+run)?\s+check:architecture(?=\s|$))/;
|
|
14
|
+
const FAIL_CLOSED = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
|
|
15
|
+
|
|
16
|
+
function shellSegments(text) {
|
|
17
|
+
const segments = [];
|
|
18
|
+
let current = '';
|
|
19
|
+
let quote = null;
|
|
20
|
+
let escaped = false;
|
|
21
|
+
const push = (terminator) => {
|
|
22
|
+
if (current.trim()) segments.push({ text: current.trim(), terminator });
|
|
23
|
+
current = '';
|
|
24
|
+
};
|
|
25
|
+
const input = String(text || '');
|
|
26
|
+
for (let index = 0; index < input.length; index++) {
|
|
27
|
+
const char = input[index];
|
|
28
|
+
if (escaped) {
|
|
29
|
+
current += char;
|
|
30
|
+
escaped = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (quote) {
|
|
34
|
+
current += char;
|
|
35
|
+
if (char === '\\' && quote === '"') escaped = true;
|
|
36
|
+
else if (char === quote) quote = null;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (char === '"' || char === "'") {
|
|
40
|
+
quote = char;
|
|
41
|
+
current += char;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (char === '#') {
|
|
45
|
+
while (index + 1 < input.length && input[index + 1] !== '\n') index++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const pair = input.slice(index, index + 2);
|
|
49
|
+
if (pair === '&&' || pair === '||') {
|
|
50
|
+
push(pair);
|
|
51
|
+
index++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (char === ';' || char === '|' || char === '\n') {
|
|
55
|
+
push(char);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
current += char;
|
|
59
|
+
}
|
|
60
|
+
push(null);
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function executableText(segment) {
|
|
65
|
+
return String(segment || '').trim().replace(ENV_PREFIX, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function analyzeCommands(commands, script = '') {
|
|
69
|
+
const scriptArk = shellSegments(script)
|
|
70
|
+
.filter((segment) => DIRECT_ARK.test(executableText(segment.text)))
|
|
71
|
+
.map((segment) => ({
|
|
72
|
+
text: segment.text,
|
|
73
|
+
enforcing: segment.terminator !== '||' && segment.terminator !== '|',
|
|
74
|
+
}));
|
|
75
|
+
const found = [];
|
|
76
|
+
for (const segment of shellSegments(commands)) {
|
|
77
|
+
const executable = executableText(segment.text);
|
|
78
|
+
const outerEnforcing = segment.terminator !== '||' && segment.terminator !== '|';
|
|
79
|
+
if (DIRECT_ARK.test(executable)) {
|
|
80
|
+
found.push({ text: segment.text, enforcing: outerEnforcing });
|
|
81
|
+
} else if (CHECK_SCRIPT.test(executable)) {
|
|
82
|
+
for (const inner of scriptArk) {
|
|
83
|
+
found.push({ text: inner.text, enforcing: outerEnforcing && inner.enforcing });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function architectureScript(root) {
|
|
91
|
+
try {
|
|
92
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
93
|
+
return typeof pkg?.scripts?.['check:architecture'] === 'string'
|
|
94
|
+
? pkg.scripts['check:architecture']
|
|
95
|
+
: '';
|
|
96
|
+
} catch {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function workflowJobs(text) {
|
|
102
|
+
const lines = String(text || '').split('\n');
|
|
103
|
+
const jobsLine = lines.findIndex((line) => /^(?:"jobs"|'jobs'|jobs):\s*(?:#.*)?$/.test(line));
|
|
104
|
+
if (jobsLine < 0) return [];
|
|
105
|
+
const jobsIndent = lines[jobsLine].match(/^\s*/)?.[0].length ?? 0;
|
|
106
|
+
let childIndent = null;
|
|
107
|
+
let jobsEnd = lines.length;
|
|
108
|
+
const headers = [];
|
|
109
|
+
for (let index = jobsLine + 1; index < lines.length; index++) {
|
|
110
|
+
const line = lines[index];
|
|
111
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
112
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
113
|
+
if (indent <= jobsIndent) {
|
|
114
|
+
jobsEnd = index;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const hit = line.match(/^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*(?:#.*)?$/);
|
|
118
|
+
if (!hit) continue;
|
|
119
|
+
childIndent ??= indent;
|
|
120
|
+
if (indent === childIndent) headers.push({ id: hit[1] ?? hit[2] ?? hit[3], index });
|
|
121
|
+
}
|
|
122
|
+
return headers.map((header, index) => ({
|
|
123
|
+
id: header.id,
|
|
124
|
+
body: lines.slice(header.index, headers[index + 1]?.index ?? jobsEnd).join('\n'),
|
|
125
|
+
indent: childIndent,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function jobPropertyIndent(job) {
|
|
130
|
+
const indents = job.body.split('\n').slice(1)
|
|
131
|
+
.filter((line) => line.trim() && !/^\s*#/.test(line))
|
|
132
|
+
.map((line) => line.match(/^\s*/)?.[0].length ?? 0)
|
|
133
|
+
.filter((indent) => indent > Number(job.indent));
|
|
134
|
+
return indents.length > 0 ? Math.min(...indents) : Number(job.indent) + 2;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function propertyValue(line, pattern) {
|
|
138
|
+
return line.match(pattern)?.[1]?.trim() ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function unsafeControl(lines, propertyIndent, needs = false) {
|
|
142
|
+
const atIndent = lines.filter((line) => {
|
|
143
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
144
|
+
return indent + (/^\s*-\s+/.test(line) ? 2 : 0) === propertyIndent;
|
|
145
|
+
});
|
|
146
|
+
const condition = atIndent.map((line) => propertyValue(line, IF_LINE)).find((value) => value !== null);
|
|
147
|
+
const continuation = atIndent
|
|
148
|
+
.map((line) => propertyValue(line, CONTINUE_LINE))
|
|
149
|
+
.find((value) => value !== null);
|
|
150
|
+
if (condition !== undefined && !SAFE_IF.test(condition)) return true;
|
|
151
|
+
if (continuation !== undefined && !SAFE_CONTINUE.test(continuation)) return true;
|
|
152
|
+
return needs && (condition === undefined || !SAFE_NEEDS_IF.test(condition));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stepBounds(lines, runIndex, runIndent, inline) {
|
|
156
|
+
let start = runIndex;
|
|
157
|
+
let stepIndent = inline ? runIndent : null;
|
|
158
|
+
if (!inline) {
|
|
159
|
+
for (let index = runIndex - 1; index >= 0; index--) {
|
|
160
|
+
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
|
161
|
+
if (/^\s*-\s+/.test(lines[index]) && indent < runIndent) {
|
|
162
|
+
start = index;
|
|
163
|
+
stepIndent = indent;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (stepIndent === null) return { start: runIndex, end: runIndex + 1, propertyIndent: runIndent };
|
|
169
|
+
let end = lines.length;
|
|
170
|
+
for (let index = start + 1; index < lines.length; index++) {
|
|
171
|
+
if (!lines[index].trim()) continue;
|
|
172
|
+
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
|
173
|
+
if (indent < stepIndent + 2 || (indent === stepIndent && /^\s*-\s+/.test(lines[index]))) {
|
|
174
|
+
end = index;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { start, end, propertyIndent: stepIndent + 2 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function unquoteRun(value) {
|
|
182
|
+
const trimmed = String(value || '').trim();
|
|
183
|
+
if (/^"(?:\\.|[^"\\])*"$/.test(trimmed)) {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(trimmed);
|
|
186
|
+
} catch {
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (/^'[^']*'$/.test(trimmed)) return trimmed.slice(1, -1).replace(/''/g, "'");
|
|
191
|
+
return trimmed;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function workflowRunText(text) {
|
|
195
|
+
const lines = String(text || '').split('\n');
|
|
196
|
+
const commands = [];
|
|
197
|
+
for (let index = 0; index < lines.length; index++) {
|
|
198
|
+
const hit = lines[index].match(/^(\s*)(?:-\s+)?run:\s*(.*)$/);
|
|
199
|
+
if (!hit) continue;
|
|
200
|
+
const inline = /^\s*-\s+run:/.test(lines[index]);
|
|
201
|
+
const bounds = stepBounds(lines, index, hit[1].length, inline);
|
|
202
|
+
if (unsafeControl(lines.slice(bounds.start, bounds.end), bounds.propertyIndent)) continue;
|
|
203
|
+
if (hit[2] && !/^[|>][-+]?\s*$/.test(hit[2])) commands.push(unquoteRun(hit[2]));
|
|
204
|
+
else {
|
|
205
|
+
const folded = /^>/.test(hit[2]);
|
|
206
|
+
const indent = hit[1].length;
|
|
207
|
+
const block = [];
|
|
208
|
+
while (index + 1 < lines.length) {
|
|
209
|
+
const next = lines[index + 1];
|
|
210
|
+
if (next.trim() && (next.match(/^\s*/)?.[0].length ?? 0) <= indent) break;
|
|
211
|
+
block.push(next.trim());
|
|
212
|
+
index++;
|
|
213
|
+
}
|
|
214
|
+
commands.push(block.join(folded ? ' ' : '\n'));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return commands.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function jobCannotEnforce(job) {
|
|
221
|
+
const propertyIndent = jobPropertyIndent(job);
|
|
222
|
+
const lines = job.body.split('\n');
|
|
223
|
+
const needs = lines.some((line) =>
|
|
224
|
+
(line.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
|
|
225
|
+
/^\s*(?:"needs"|'needs'|needs):/.test(line)
|
|
226
|
+
);
|
|
227
|
+
return unsafeControl(lines, propertyIndent, needs);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function commandsRunArk(commands, script) {
|
|
231
|
+
return analyzeCommands(commands, script).length > 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function commandsEnforceArk(commands, script) {
|
|
235
|
+
return analyzeCommands(commands, script).some((entry) => entry.enforcing && FAIL_CLOSED.test(entry.text));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function activeWorkflowRunText(text) {
|
|
239
|
+
const jobs = workflowJobs(text);
|
|
240
|
+
return jobs.filter((job) => !jobCannotEnforce(job)).map((job) => workflowRunText(job.body)).join('\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function runsArkCheck(text, script = '') {
|
|
244
|
+
return commandsRunArk(activeWorkflowRunText(text), script);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function enforcingArkRunText(text, script = '') {
|
|
248
|
+
return analyzeCommands(activeWorkflowRunText(text), script)
|
|
249
|
+
.filter((entry) => entry.enforcing)
|
|
250
|
+
.map((entry) => entry.text)
|
|
251
|
+
.join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function workflowFiles(root) {
|
|
255
|
+
const directory = path.join(root, '.github', 'workflows');
|
|
256
|
+
try {
|
|
257
|
+
return fs.readdirSync(directory)
|
|
258
|
+
.filter((file) => /\.ya?ml$/i.test(file))
|
|
259
|
+
.map((file) => ({ file, text: fs.readFileSync(path.join(directory, file), 'utf8') }));
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function jobName(job) {
|
|
266
|
+
const propertyIndent = jobPropertyIndent(job);
|
|
267
|
+
const line = job.body.split('\n').find((candidate) =>
|
|
268
|
+
(candidate.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
|
|
269
|
+
/^\s*(?:"name"|'name'|name):/.test(candidate)
|
|
270
|
+
);
|
|
271
|
+
const raw = line?.match(/^\s*(?:"name"|'name'|name):\s*(.+?)\s*$/)?.[1];
|
|
272
|
+
if (!raw) return { name: null, dynamic: false };
|
|
273
|
+
const name = raw.replace(/^(['"])(.*)\1$/, '$2');
|
|
274
|
+
return { name: name.includes('${{') ? null : name, dynamic: name.includes('${{') };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function jobHasMatrix(job) {
|
|
278
|
+
const propertyIndent = jobPropertyIndent(job);
|
|
279
|
+
const lines = job.body.split('\n');
|
|
280
|
+
const strategy = lines.findIndex((line) =>
|
|
281
|
+
(line.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
|
|
282
|
+
/^\s*(?:"strategy"|'strategy'|strategy):/.test(line)
|
|
283
|
+
);
|
|
284
|
+
if (strategy < 0) return false;
|
|
285
|
+
for (let index = strategy + 1; index < lines.length; index++) {
|
|
286
|
+
if (!lines[index].trim()) continue;
|
|
287
|
+
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
|
288
|
+
if (indent <= propertyIndent) break;
|
|
289
|
+
if (/^\s*(?:"matrix"|'matrix'|matrix):/.test(lines[index])) return true;
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function localArkContexts(root) {
|
|
295
|
+
const script = architectureScript(root);
|
|
296
|
+
const known = new Set();
|
|
297
|
+
const occurrences = new Map();
|
|
298
|
+
const arkOccurrences = new Map();
|
|
299
|
+
let dynamic = false;
|
|
300
|
+
for (const workflow of workflowFiles(root)) {
|
|
301
|
+
for (const job of workflowJobs(workflow.text)) {
|
|
302
|
+
const explicit = jobName(job);
|
|
303
|
+
const matrix = jobHasMatrix(job);
|
|
304
|
+
const enforcing = !jobCannotEnforce(job) &&
|
|
305
|
+
commandsEnforceArk(workflowRunText(job.body), script);
|
|
306
|
+
if (enforcing) {
|
|
307
|
+
known.add(job.id);
|
|
308
|
+
if (explicit.name) known.add(explicit.name);
|
|
309
|
+
}
|
|
310
|
+
if (explicit.dynamic || matrix) {
|
|
311
|
+
dynamic ||= enforcing;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const context = explicit.name ?? job.id;
|
|
315
|
+
occurrences.set(context, (occurrences.get(context) ?? 0) + 1);
|
|
316
|
+
if (enforcing) arkOccurrences.set(context, (arkOccurrences.get(context) ?? 0) + 1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const contexts = new Set();
|
|
320
|
+
for (const [context, count] of arkOccurrences) {
|
|
321
|
+
if (count === 1 && occurrences.get(context) === 1) contexts.add(context);
|
|
322
|
+
else dynamic = true;
|
|
323
|
+
}
|
|
324
|
+
return { contexts, known, dynamic };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Historical name retained; the set also contains literal, non-dynamic job names. */
|
|
328
|
+
export function jobIdsThatRunArkCheck(root) {
|
|
329
|
+
return localArkContexts(root).known;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function isArkRequiredStatusCheck(root, requiredNames) {
|
|
333
|
+
if (!Array.isArray(requiredNames) || requiredNames.length === 0) return false;
|
|
334
|
+
const local = localArkContexts(root).contexts;
|
|
335
|
+
return requiredNames.some((name) => local.has(String(name)));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function classifyRequired(root, legacyContexts, checks, bindingField) {
|
|
339
|
+
const local = localArkContexts(root);
|
|
340
|
+
const exact = (name) => local.contexts.has(String(name));
|
|
341
|
+
const matching = checks.filter((check) => exact(check.context));
|
|
342
|
+
if (matching.some((check) => check[bindingField] == null || check[bindingField] === -1)) return true;
|
|
343
|
+
if (matching.length > 0) return 'unverified';
|
|
344
|
+
if (legacyContexts.some(exact)) return true;
|
|
345
|
+
return local.dynamic && (legacyContexts.length > 0 || checks.length > 0)
|
|
346
|
+
? 'unverified'
|
|
347
|
+
: false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function combineRequired(classic, rules, classicAvailable, rulesAvailable, hasWorkflowRule) {
|
|
351
|
+
if (classic === true || rules === true) return true;
|
|
352
|
+
if (classic === 'unverified' || rules === 'unverified' || hasWorkflowRule) return 'unverified';
|
|
353
|
+
return classicAvailable && rulesAvailable ? false : 'unverified';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function parseJson(result) {
|
|
357
|
+
if (result.status !== 0) return null;
|
|
358
|
+
try {
|
|
359
|
+
return JSON.parse(result.stdout || 'null');
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Query classic branch protection and all active branch rules before reporting absence. */
|
|
366
|
+
export function reportGithubBranchProtection(opts = {}) {
|
|
367
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
368
|
+
const env = opts.env ?? process.env;
|
|
369
|
+
if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
|
|
370
|
+
return { available: false, reason: 'gh-cli-unavailable' };
|
|
371
|
+
}
|
|
372
|
+
let repo = opts.repo;
|
|
373
|
+
let branch = opts.branch;
|
|
374
|
+
if (!repo || !branch) {
|
|
375
|
+
const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'nameWithOwner,defaultBranchRef'];
|
|
376
|
+
const metadata = parseJson(spawnSync('gh', args, { cwd, encoding: 'utf8', env }));
|
|
377
|
+
if (!metadata?.nameWithOwner || !metadata?.defaultBranchRef?.name) {
|
|
378
|
+
return { available: false, reason: 'gh-repo-unavailable' };
|
|
379
|
+
}
|
|
380
|
+
repo ??= metadata.nameWithOwner;
|
|
381
|
+
branch ??= metadata.defaultBranchRef.name;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const classicResult = spawnSync('gh', [
|
|
385
|
+
'api', `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`, '--jq',
|
|
386
|
+
'{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
|
|
387
|
+
], { cwd, encoding: 'utf8', env });
|
|
388
|
+
const rulesResult = spawnSync(
|
|
389
|
+
'gh', ['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`],
|
|
390
|
+
{ cwd, encoding: 'utf8', env }
|
|
391
|
+
);
|
|
392
|
+
const classic = parseJson(classicResult);
|
|
393
|
+
const rules = parseJson(rulesResult);
|
|
394
|
+
const classicAvailable = classic !== null && !Array.isArray(classic);
|
|
395
|
+
const rulesAvailable = Array.isArray(rules);
|
|
396
|
+
|
|
397
|
+
const contexts = classicAvailable && Array.isArray(classic.contexts)
|
|
398
|
+
? classic.contexts.map(String)
|
|
399
|
+
: [];
|
|
400
|
+
const checks = classicAvailable && Array.isArray(classic.checks)
|
|
401
|
+
? classic.checks.map((check) => ({
|
|
402
|
+
context: String(check?.context || check?.name || ''),
|
|
403
|
+
app_id: Number.isInteger(check?.app_id) ? check.app_id : null,
|
|
404
|
+
})).filter((check) => check.context)
|
|
405
|
+
: [];
|
|
406
|
+
const statusRules = rulesAvailable
|
|
407
|
+
? rules.filter((rule) => rule?.type === 'required_status_checks')
|
|
408
|
+
.flatMap((rule) => rule?.parameters?.required_status_checks ?? [])
|
|
409
|
+
.map((check) => ({
|
|
410
|
+
context: String(check?.context || ''),
|
|
411
|
+
integration_id: Number.isInteger(check?.integration_id) ? check.integration_id : null,
|
|
412
|
+
})).filter((check) => check.context)
|
|
413
|
+
: [];
|
|
414
|
+
const hasWorkflowRule = rulesAvailable && rules.some((rule) => rule?.type === 'workflows');
|
|
415
|
+
const classicRequired = classicAvailable
|
|
416
|
+
? classifyRequired(cwd, contexts, checks, 'app_id')
|
|
417
|
+
: 'unverified';
|
|
418
|
+
const rulesRequired = rulesAvailable
|
|
419
|
+
? classifyRequired(cwd, [], statusRules, 'integration_id')
|
|
420
|
+
: 'unverified';
|
|
421
|
+
const arkCheckRequired = combineRequired(
|
|
422
|
+
classicRequired, rulesRequired, classicAvailable, rulesAvailable, hasWorkflowRule
|
|
423
|
+
);
|
|
424
|
+
const arkCheckSourceBound = arkCheckRequired === true ? false : 'unverified';
|
|
425
|
+
const available = arkCheckRequired === true || (classicAvailable && rulesAvailable);
|
|
426
|
+
const all = [...new Set([...contexts, ...checks.map((check) => check.context), ...statusRules.map((check) => check.context)])];
|
|
427
|
+
const error = `${classicResult.stderr || ''}${rulesResult.stderr || ''}`.slice(0, 400);
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
available,
|
|
431
|
+
reason: available ? 'ok' : 'provider-enforcement-unverified',
|
|
432
|
+
repo,
|
|
433
|
+
branch,
|
|
434
|
+
requiredStatusChecks: all,
|
|
435
|
+
requiredStatusCheckDetails: checks,
|
|
436
|
+
requiredStatusRuleDetails: statusRules,
|
|
437
|
+
strict: classicAvailable ? Boolean(classic.strict) : null,
|
|
438
|
+
enforcesAdmins: classicAvailable ? Boolean(classic.enforcesAdmins) : null,
|
|
439
|
+
arkCheckRequired,
|
|
440
|
+
arkCheckSourceBound,
|
|
441
|
+
raw: { classic, rules, ...(error ? { error } : {}) },
|
|
442
|
+
};
|
|
443
|
+
}
|
|
@@ -1,151 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { execCommandParts, execRunner } from '../ark-shared.mjs';
|
|
6
|
-
|
|
7
|
-
/** Preferred MCP binary name for generated hooks (package dual-bin). */
|
|
8
|
-
export const PREFERRED_MCP_BIN = 'arkgate-mcp';
|
|
9
|
-
|
|
10
|
-
export function claudeSettings(root) {
|
|
11
|
-
const runner = execRunner(root);
|
|
12
|
-
return `${JSON.stringify({
|
|
13
|
-
hooks: {
|
|
14
|
-
// Inject the contract at session start so the agent knows the architecture from
|
|
15
|
-
// the first token. Project-scoped by design; --session-context is also a silent
|
|
16
|
-
// no-op when no ark.config.json exists, so it can never leak into other projects.
|
|
17
|
-
SessionStart: [
|
|
18
|
-
{
|
|
19
|
-
hooks: [
|
|
20
|
-
{
|
|
21
|
-
type: 'command',
|
|
22
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
23
|
-
},
|
|
24
|
-
],
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
PreToolUse: [
|
|
28
|
-
{
|
|
29
|
-
matcher: 'Write|Edit|MultiEdit',
|
|
30
|
-
hooks: [
|
|
31
|
-
{
|
|
32
|
-
type: 'command',
|
|
33
|
-
// W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
|
|
34
|
-
// (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
|
|
35
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
36
|
-
},
|
|
37
|
-
],
|
|
38
|
-
},
|
|
39
|
-
],
|
|
40
|
-
},
|
|
41
|
-
}, null, 2)}\n`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function codexHooks(root) {
|
|
45
|
-
const runner = execRunner(root);
|
|
46
|
-
const codexRoot = '${CODEX_PROJECT_DIR:-${PWD:-.}}';
|
|
47
|
-
return `${JSON.stringify({
|
|
48
|
-
hooks: {
|
|
49
|
-
SessionStart: [
|
|
50
|
-
{
|
|
51
|
-
hooks: [
|
|
52
|
-
{
|
|
53
|
-
type: 'command',
|
|
54
|
-
timeout: 30,
|
|
55
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${codexRoot}" --config ark.config.json`,
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
PreToolUse: [
|
|
61
|
-
{
|
|
62
|
-
matcher: 'ApplyPatch|apply_patch|Write|Edit|MultiEdit',
|
|
63
|
-
hooks: [
|
|
64
|
-
{
|
|
65
|
-
type: 'command',
|
|
66
|
-
timeout: 30,
|
|
67
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${codexRoot}" --config ark.config.json`,
|
|
68
|
-
},
|
|
69
|
-
],
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
},
|
|
73
|
-
}, null, 2)}\n`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Codex project config: modern Codex resolves .codex/config.toml from the active
|
|
77
|
-
// project, so the primary `ark` binding can stay local instead of competing in
|
|
78
|
-
// the user's global $CODEX_HOME across every adopted repository.
|
|
79
|
-
export function codexProjectConfig(root) {
|
|
80
|
-
const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
|
|
81
|
-
'--root',
|
|
82
|
-
'.',
|
|
83
|
-
'--config',
|
|
84
|
-
'ark.config.json',
|
|
85
|
-
]);
|
|
86
|
-
const esc = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
87
|
-
const argsToml = args.map((value) => `"${esc(value)}"`).join(', ');
|
|
88
|
-
return `# Generated by ark-check --install-agent-gates (Codex project scope).
|
|
1
|
+
// Generated from hook-templates.source.mjs — run npm run generate:packaged-tooling.
|
|
2
|
+
import{execCommandParts as i,execRunner as a}from"../ark-shared.mjs";const r="arkgate-mcp";function g(e){const o=a(e);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${o} ${r} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}]}},null,2)}
|
|
3
|
+
`}function l(e){const o=a(e),n="${CODEX_PROJECT_DIR:-${PWD:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${o} ${r} --session-context --root "${n}" --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "${n}" --config ark.config.json`}]}]}},null,2)}
|
|
4
|
+
`}function p(e){const{command:o,args:n}=i(e,r,["--root",".","--config","ark.config.json"]),t=c=>c.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),s=n.map(c=>`"${t(c)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
|
|
89
5
|
# Restart Codex after changes; MCP servers are loaded when the project session starts.
|
|
90
6
|
[mcp_servers.ark]
|
|
91
|
-
command = "${
|
|
92
|
-
args = [${
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Grok Build project config: MCP registration (commit-friendly relative paths — unlike
|
|
97
|
-
// the optional Codex home fallback, Grok loads .grok/config.toml from the project).
|
|
98
|
-
export function grokProjectConfig(root) {
|
|
99
|
-
const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
|
|
100
|
-
'--root',
|
|
101
|
-
'.',
|
|
102
|
-
'--config',
|
|
103
|
-
'ark.config.json',
|
|
104
|
-
]);
|
|
105
|
-
const argsToml = args.map((value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(', ');
|
|
106
|
-
return `# Generated by ark-check --install-agent-gates (Grok Build project scope).
|
|
107
|
-
# Restart Grok (or /mcps → refresh) after changes. Also loads repo-root .mcp.json.
|
|
7
|
+
command = "${t(o)}"
|
|
8
|
+
args = [${s}]
|
|
9
|
+
`}function k(e){const{command:o,args:n}=i(e,r,["--root",".","--config","ark.config.json"]),t=n.map(s=>`"${s.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
|
|
10
|
+
# Restart Grok (or /mcps \u2192 refresh) after changes. Also loads repo-root .mcp.json.
|
|
108
11
|
[mcp_servers.ark]
|
|
109
|
-
command = "${
|
|
110
|
-
args = [${
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets both
|
|
115
|
-
// GROK_WORKSPACE_ROOT and CLAUDE_PROJECT_DIR (Claude-compatible alias). Prefer
|
|
116
|
-
// GROK_* with fallback so hooks still work if only one is present.
|
|
117
|
-
// Matcher keeps Claude names (Write|Edit|MultiEdit) and Grok natives
|
|
118
|
-
// (write|search_replace) — Grok aliases both directions.
|
|
119
|
-
export function grokHooks(root) {
|
|
120
|
-
const runner = execRunner(root);
|
|
121
|
-
// Nested defaults: Grok native → Claude alias → project cwd (hook cwd is the workspace).
|
|
122
|
-
const grokRoot = '${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}';
|
|
123
|
-
return `${JSON.stringify({
|
|
124
|
-
hooks: {
|
|
125
|
-
SessionStart: [
|
|
126
|
-
{
|
|
127
|
-
hooks: [
|
|
128
|
-
{
|
|
129
|
-
type: 'command',
|
|
130
|
-
timeout: 30,
|
|
131
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${grokRoot}" --config ark.config.json`,
|
|
132
|
-
},
|
|
133
|
-
],
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
PreToolUse: [
|
|
137
|
-
{
|
|
138
|
-
matcher: 'Write|Edit|MultiEdit|write|search_replace',
|
|
139
|
-
hooks: [
|
|
140
|
-
{
|
|
141
|
-
type: 'command',
|
|
142
|
-
timeout: 30,
|
|
143
|
-
// W4: --hook-repair → structured autoPatch on deny (hard block still).
|
|
144
|
-
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
},
|
|
150
|
-
}, null, 2)}\n`;
|
|
151
|
-
}
|
|
12
|
+
command = "${o.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
|
|
13
|
+
args = [${t}]
|
|
14
|
+
`}function d(e){const o=a(e),n="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${o} ${r} --session-context --root "${n}" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "${n}" --config ark.config.json`}]}]}},null,2)}
|
|
15
|
+
`}export{r as PREFERRED_MCP_BIN,g as claudeSettings,l as codexHooks,p as codexProjectConfig,d as grokHooks,k as grokProjectConfig};
|
|
@@ -230,7 +230,7 @@ function parseHealthHtml(health) {
|
|
|
230
230
|
(health.truncated > 0 ? `<p class="muted">…(+${health.truncated} more affected file(s); doctor list capped)</p>` : '');
|
|
231
231
|
return `
|
|
232
232
|
<section data-advisory="parseHealth">
|
|
233
|
-
<h2>Parse health <span class="muted">(
|
|
233
|
+
<h2>Parse health <span class="muted">(completeness evidence — affected syntax makes analysis partial)</span></h2>
|
|
234
234
|
${body}
|
|
235
235
|
</section>`;
|
|
236
236
|
}
|
|
@@ -106,6 +106,14 @@ export function renderWritePathAdoptionBlock(writePath) {
|
|
|
106
106
|
const gapNote = writePath.gap
|
|
107
107
|
? ` Gap: <b>${esc(writePath.gap.id)}</b> — ${esc(writePath.gap.message || '')}`
|
|
108
108
|
: '';
|
|
109
|
+
const state = writePath.enforcementState;
|
|
110
|
+
const value = (entry) => entry === true ? 'yes' : entry === false ? 'no' : String(entry);
|
|
111
|
+
const boundary = (label, entry) => entry
|
|
112
|
+
? `<li><b>${esc(label)}</b> — supported ${esc(value(entry.supported))}; analyzed ${esc(value(entry.analyzed))}; configured ${esc(value(entry.configured))}; installed ${esc(value(entry.installed))}; runtime observed ${esc(value(entry.runtimeObserved))}; operation ${esc(entry.operation ?? 'none')}; operation covered ${esc(value(entry.operationCoverage))}; active ${esc(value(entry.active))}; bypassable ${esc(value(entry.bypassable))}; required ${esc(value(entry.required))}; hard ${esc(value(entry.hard))}</li>`
|
|
113
|
+
: '';
|
|
114
|
+
const stateHtml = state
|
|
115
|
+
? `<ul class="senior-list" style="margin:.45rem 0 0">${boundary('Local write', state.localWrite)}${boundary('Advisory MCP', state.advisoryMcp)}${boundary('CI merge', state.ciMerge)}</ul>`
|
|
116
|
+
: '';
|
|
109
117
|
// invLine / gapNote already include escaped user content; only plain strings go through esc().
|
|
110
118
|
return `<div class="write-path-block" title="${esc(writePathModeHint(mode))}">
|
|
111
119
|
<p class="dim" style="margin:.65rem 0 .2rem;font-size:.84rem">
|
|
@@ -117,6 +125,7 @@ export function renderWritePathAdoptionBlock(writePath) {
|
|
|
117
125
|
<p class="kpi-hint" style="max-width:none;margin:0">
|
|
118
126
|
${esc(writePathModeHint(mode))} ${invLine}${esc(unknownNote)}${gapNote}
|
|
119
127
|
</p>
|
|
128
|
+
${stateHtml}
|
|
120
129
|
</div>`;
|
|
121
130
|
}
|
|
122
131
|
|
package/bin/lib/html-report.mjs
CHANGED
|
@@ -51,9 +51,9 @@ export function detectEnforcement(root) {
|
|
|
51
51
|
'.grok/hooks/ark-write-gate.json') ||
|
|
52
52
|
null;
|
|
53
53
|
return [
|
|
54
|
-
{ name: 'Write gate', where: writeGateFile, what: '
|
|
54
|
+
{ name: 'Write gate', where: writeGateFile, what: 'configured local gate; active blocking depends on observed host/runtime evidence' },
|
|
55
55
|
{ name: 'ESLint', where: eslintFile || null, what: 'flags violations in your editor' },
|
|
56
|
-
{ name: 'CI check', where: workflowsMentionArk(), what: '
|
|
56
|
+
{ name: 'CI check', where: workflowsMentionArk(), what: 'runs Ark; merge blocking requires provider-confirmed required status' },
|
|
57
57
|
{ name: 'Baseline', where: has('.ark-baseline.json') ? '.ark-baseline.json' : null, what: 'old violations frozen; new ones fail' },
|
|
58
58
|
].map((e) => ({ ...e, on: !!e.where }));
|
|
59
59
|
}
|
|
@@ -1061,8 +1061,8 @@ export function renderHtmlReport({
|
|
|
1061
1061
|
)}
|
|
1062
1062
|
${metricKpi(
|
|
1063
1063
|
`${gatesOn}/${enforcement.length}`,
|
|
1064
|
-
'Gates
|
|
1065
|
-
'Write hook, CI workflow, ESLint plugin, and baseline file — how many enforcement
|
|
1064
|
+
'Gates configured',
|
|
1065
|
+
'Write hook, CI workflow, ESLint plugin, and baseline file — how many enforcement surfaces are present, not proof that each is active or required.'
|
|
1066
1066
|
)}
|
|
1067
1067
|
${metricKpi(
|
|
1068
1068
|
`${violations.length}${suppressed ? ` · ${suppressed}Δ` : ''}`,
|
|
@@ -1222,7 +1222,7 @@ export function renderHtmlReport({
|
|
|
1222
1222
|
['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, ''],
|
|
1223
1223
|
['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, ''],
|
|
1224
1224
|
['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, ''],
|
|
1225
|
-
['Gates
|
|
1225
|
+
['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
|
|
1226
1226
|
];
|
|
1227
1227
|
const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
|
|
1228
1228
|
const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
|