arkgate 4.8.2 → 4.8.4
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 +257 -3
- package/README.md +47 -9
- package/bin/ark-check-runtime.mjs +340 -5
- package/bin/ark-layer-match.mjs +170 -13
- package/bin/ark-mcp-runtime.mjs +9 -2
- package/bin/lib/analysis-completeness.mjs +86 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/ark-order-facts.mjs +59 -0
- package/bin/lib/ark-order-sensors.mjs +31 -2
- package/bin/lib/arkrule-file-hints.mjs +6 -2
- package/bin/lib/arkrules-contract.mjs +9 -1
- package/bin/lib/arkrules-sensors.mjs +22 -2
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/config-extras.mjs +2 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +8 -2
- package/bin/lib/first-run-help.mjs +12 -0
- package/bin/lib/invariant-coverage-io.mjs +175 -19
- package/bin/lib/invariant-coverage.mjs +110 -7
- package/bin/lib/literal-path-drift-io.mjs +569 -0
- package/bin/lib/literal-path-drift.mjs +761 -0
- package/bin/lib/policy-delta-io.mjs +5 -0
- package/bin/lib/remediation.mjs +24 -1
- package/bin/lib/resolved-candidate-facts.mjs +31 -0
- package/bin/lib/rules-under-contract.mjs +5 -0
- package/bin/lib/scan-files.mjs +54 -0
- package/bin/lib/sensor-promote-cli.mjs +372 -0
- package/bin/lib/sensor-promote-io.mjs +246 -0
- package/bin/lib/sensor-promotion.mjs +363 -0
- package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
- package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
- package/dist/eslint/index.cjs +5 -5
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +5 -5
- package/dist/index.cjs +31 -31
- package/dist/index.d.ts +85 -7
- package/dist/index.js +31 -31
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +4 -4
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +89 -9
- package/docs/develop.md +24 -2
- package/docs/diagnostics.md +79 -1
- package/docs/enthusiast/README.md +6 -4
- package/docs/package-surface.md +36 -4
- package/docs/product-voice.md +15 -5
- package/docs/use.md +8 -5
- package/package.json +2 -2
- package/schemas/ark.arkrules.schema.json +1 -0
- package/schemas/ark.config.schema.json +72 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
- package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +4 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/agent-skills/ark-place/SKILL.md +6 -2
- package/templates/arkrules/ApplicationOrchestration.json +6 -0
- package/templates/skills/ark-adopt.md +13 -3
- package/templates/skills/ark-autopilot.md +1 -1
- package/templates/skills/ark-contract.md +4 -0
- package/templates/skills/ark-coverage.md +1 -0
- package/templates/skills/ark-place.md +6 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooling adapter for the sensor promotability map and the promotion write.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written (NOT generated): the pure projection lives in
|
|
5
|
+
* `src/domain/sensorPromotion.ts` → `bin/lib/sensor-promotion.mjs`, and
|
|
6
|
+
* everything with a filesystem in it belongs here.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { buildSensorMap, promoteRuleInArkRulesText, ruleCountKey } from './sensor-promotion.mjs';
|
|
12
|
+
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
13
|
+
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
14
|
+
import {
|
|
15
|
+
coverageOptionsFromConfig,
|
|
16
|
+
invariantIdsFromCatalog,
|
|
17
|
+
loadInvariantCoverageInputs,
|
|
18
|
+
} from './invariant-coverage-io.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The root as the filesystem sees it. Comparing a realpath against a lexical
|
|
22
|
+
* root is a guaranteed mismatch wherever the root itself sits behind a link
|
|
23
|
+
* (`/var` on macOS, a symlinked checkout, a bind mount), and every containment
|
|
24
|
+
* test would then answer "outside". Same helper pair as literal-path-drift-io.
|
|
25
|
+
*/
|
|
26
|
+
function realRoot(root) {
|
|
27
|
+
const resolved = path.resolve(root);
|
|
28
|
+
try {
|
|
29
|
+
return fs.realpathSync.native(resolved);
|
|
30
|
+
} catch {
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isInsideRoot(real, rootReal) {
|
|
36
|
+
return real === rootReal || real.startsWith(rootReal + path.sep);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the promotability map for a project, WITHOUT the TypeScript resolver.
|
|
41
|
+
*
|
|
42
|
+
* Everything here is a declaration read off disk: the contract, the ArkRules
|
|
43
|
+
* documents it points at, and — for invariants — the coverage evidence scan,
|
|
44
|
+
* which is a filesystem walk plus a text match and never executes anything. A
|
|
45
|
+
* full `ark-check` on a real repository takes ~160s; this answers in the time
|
|
46
|
+
* it takes to read the files.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} root
|
|
49
|
+
* @param {Record<string, unknown>} config
|
|
50
|
+
* @returns {{ ok: true, map: object, coverage: { partial: boolean, evaluated: boolean } }
|
|
51
|
+
* | { ok: false, reason: string, issues?: object[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function loadSensorMap(root, config, facts) {
|
|
54
|
+
const refs = config?.arkRules;
|
|
55
|
+
if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) {
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
map: buildSensorMap({ structure: [], invariants: [] }),
|
|
59
|
+
coverage: { partial: false, evaluated: false },
|
|
60
|
+
arkRulesActive: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const loaded = loadEffectiveArkRulesFromDisk(root, config);
|
|
64
|
+
if (loaded.errors?.length) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason:
|
|
68
|
+
'ArkRules references failed to load, so no promotability can be reported (fail closed).',
|
|
69
|
+
issues: loaded.errors,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const structure = (loaded.arkRules.structure ?? []).map((entry) => ({
|
|
73
|
+
id: entry.id,
|
|
74
|
+
sensor: entry.sensor,
|
|
75
|
+
mode: entry.mode ?? 'advisory',
|
|
76
|
+
layer: entry.provenance?.layer ?? null,
|
|
77
|
+
sourceFile: entry.provenance?.sourceFile ?? null,
|
|
78
|
+
description: entry.description ?? null,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
const catalogued = loaded.arkRules.invariants ?? [];
|
|
82
|
+
let coverageRows = [];
|
|
83
|
+
let partial = false;
|
|
84
|
+
if (catalogued.length > 0) {
|
|
85
|
+
const inputs = loadInvariantCoverageInputs(root, facts ?? { files: [] }, {
|
|
86
|
+
invariantIds: invariantIdsFromCatalog(loaded.arkRules),
|
|
87
|
+
...coverageOptionsFromConfig(config),
|
|
88
|
+
});
|
|
89
|
+
const coverage = evaluateInvariantCoverage({
|
|
90
|
+
arkRules: loaded.arkRules,
|
|
91
|
+
fileContents: inputs.fileContents,
|
|
92
|
+
testFiles: inputs.testFiles,
|
|
93
|
+
testGlobsMissing: inputs.testGlobsMissing,
|
|
94
|
+
coverageBudgetExhausted: inputs.coverageBudgetExhausted === true,
|
|
95
|
+
...(inputs.stats ? { coverageStats: inputs.stats } : {}),
|
|
96
|
+
// The declared roots decide `outsideDeclaredRoots`, which is one of the
|
|
97
|
+
// four reasons canPromoteInvariant refuses. Dropping them here would make
|
|
98
|
+
// this surface promise a promotion the gate then denies.
|
|
99
|
+
...(inputs.coverageRoots ? { coverageRoots: inputs.coverageRoots } : {}),
|
|
100
|
+
});
|
|
101
|
+
coverageRows = coverage.coverage ?? [];
|
|
102
|
+
partial = coverage.partial === true;
|
|
103
|
+
}
|
|
104
|
+
const evidenceById = new Map(coverageRows.map((row) => [row.invariantId, row]));
|
|
105
|
+
|
|
106
|
+
const invariants = catalogued.map((entry) => ({
|
|
107
|
+
id: entry.id,
|
|
108
|
+
mode: entry.mode ?? 'advisory',
|
|
109
|
+
layer: entry.provenance?.layer ?? null,
|
|
110
|
+
sourceFile: entry.provenance?.sourceFile ?? null,
|
|
111
|
+
description: entry.description ?? null,
|
|
112
|
+
...(evidenceById.has(entry.id) ? { coverage: evidenceById.get(entry.id) } : {}),
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
map: buildSensorMap({ structure, invariants }),
|
|
118
|
+
coverage: { partial, evaluated: catalogued.length > 0 },
|
|
119
|
+
arkRulesActive: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Count the findings each declared rule produced in an analysis that already
|
|
125
|
+
* ran. One run answers for every rule: the loop this replaces was one full
|
|
126
|
+
* ~160s run per attempted promotion.
|
|
127
|
+
*
|
|
128
|
+
* Structure sensors stamp `arkruleId` AND `arkruleSource` on every violation
|
|
129
|
+
* they emit (`baseViolation` in arkRuleSensors.ts) and so do the
|
|
130
|
+
* invariant-coverage findings, so grouping by that pair is the per-rule count —
|
|
131
|
+
* there is no per-rule evaluator to call, and inventing one would be a second
|
|
132
|
+
* opinion that could disagree with the gate.
|
|
133
|
+
*
|
|
134
|
+
* The pair, not the bare id: ids are unique per DOCUMENT (`validateSemantics`
|
|
135
|
+
* keeps its `seen` set per file and `buildEffectiveArkRules` concatenates the
|
|
136
|
+
* layers without a second check), so two layer files declaring `shared-id`
|
|
137
|
+
* would pool their findings under it and each row would report the other's as
|
|
138
|
+
* its own.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<object>} findings violations and warnings from the same run
|
|
141
|
+
* @returns {Record<string, number>} `<sourceFile>#<ruleId>` → count
|
|
142
|
+
*/
|
|
143
|
+
export function countFindingsByRule(findings) {
|
|
144
|
+
/** @type {Record<string, number>} */
|
|
145
|
+
const counts = {};
|
|
146
|
+
for (const finding of findings ?? []) {
|
|
147
|
+
const id = finding?.arkruleId;
|
|
148
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
149
|
+
const source = typeof finding?.arkruleSource === 'string' ? finding.arkruleSource : null;
|
|
150
|
+
const key = ruleCountKey(source, id);
|
|
151
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
152
|
+
}
|
|
153
|
+
return counts;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Set one rule to `mode: "enforced"` in the ArkRules document that declares it.
|
|
158
|
+
*
|
|
159
|
+
* The containment discipline is the one `--write` on `--path-drift` had to
|
|
160
|
+
* learn the hard way: the parent must still be inside the root as the
|
|
161
|
+
* filesystem sees it, the read-modify-write goes through a single
|
|
162
|
+
* `O_NOFOLLOW` descriptor so the LEAF is never resolved twice, and a hard link
|
|
163
|
+
* is refused because lstat reports one as an ordinary file while the write
|
|
164
|
+
* lands on a shared inode.
|
|
165
|
+
*
|
|
166
|
+
* What it does NOT close, stated rather than implied: `O_NOFOLLOW` guards the
|
|
167
|
+
* final component only. A local attacker who can swap an ANCESTOR directory for
|
|
168
|
+
* a symlink between the parent realpath check and the open still wins the race.
|
|
169
|
+
* Both writers in this package share that gap; closing it needs an
|
|
170
|
+
* openat-relative walk, which is a change to both, not to this one.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} root
|
|
173
|
+
* @param {string} sourceFile project-relative path to the ArkRules document
|
|
174
|
+
* @param {string} ruleId
|
|
175
|
+
* @param {string} [expectedSensor] the sensor the rule had when it was priced
|
|
176
|
+
* @returns {{ ok: boolean, file: string, reason: string }}
|
|
177
|
+
*/
|
|
178
|
+
export function writeRulePromotion(root, sourceFile, ruleId, expectedSensor) {
|
|
179
|
+
const rootResolved = path.resolve(root);
|
|
180
|
+
const rootReal = realRoot(root);
|
|
181
|
+
const relative = String(sourceFile ?? '').replace(/\\/g, '/');
|
|
182
|
+
const fail = (reason) => ({ ok: false, file: relative, reason });
|
|
183
|
+
if (!relative) return fail('no source file recorded for this rule');
|
|
184
|
+
const absolute = path.resolve(rootResolved, relative);
|
|
185
|
+
if (!isInsideRoot(absolute, rootResolved)) return fail('outside-root');
|
|
186
|
+
let realParent;
|
|
187
|
+
try {
|
|
188
|
+
realParent = fs.realpathSync.native(path.dirname(absolute));
|
|
189
|
+
} catch {
|
|
190
|
+
return fail('unreadable');
|
|
191
|
+
}
|
|
192
|
+
if (!isInsideRoot(realParent, rootReal)) return fail('outside-root');
|
|
193
|
+
|
|
194
|
+
let fd;
|
|
195
|
+
try {
|
|
196
|
+
fd = fs.openSync(absolute, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const code = error?.code;
|
|
199
|
+
return fail(code === 'ELOOP' || code === 'EMLINK' ? 'symlink' : 'unwritable');
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const stat = fs.fstatSync(fd);
|
|
203
|
+
if (stat.nlink > 1) return fail('hard-link');
|
|
204
|
+
const buffer = Buffer.alloc(stat.size);
|
|
205
|
+
fs.readSync(fd, buffer, 0, stat.size, 0);
|
|
206
|
+
const text = buffer.toString('utf8');
|
|
207
|
+
// Reading as utf8 turns an invalid byte into U+FFFD and writing the whole
|
|
208
|
+
// string back would destroy it, anywhere in the file. Round-tripping the
|
|
209
|
+
// buffer is the exact test.
|
|
210
|
+
if (!Buffer.from(text, 'utf8').equals(buffer)) return fail('not-utf8');
|
|
211
|
+
const result = promoteRuleInArkRulesText(text, ruleId, expectedSensor);
|
|
212
|
+
if (!result.ok) return fail(result.reason);
|
|
213
|
+
const out = Buffer.from(result.text, 'utf8');
|
|
214
|
+
// Write BEFORE truncating, and only truncate once every byte is down. A
|
|
215
|
+
// truncate-then-write on the live descriptor turns an ENOSPC, an EIO or a
|
|
216
|
+
// signal into an empty or half-written contract; this order leaves a
|
|
217
|
+
// superset of the old document instead, which still parses back to a
|
|
218
|
+
// contract the loader can read.
|
|
219
|
+
let offset = 0;
|
|
220
|
+
while (offset < out.length) {
|
|
221
|
+
const wrote = fs.writeSync(fd, out, offset, out.length - offset, offset);
|
|
222
|
+
// A zero-byte write is not progress; looping on it would spin forever.
|
|
223
|
+
if (!(wrote > 0)) return fail('short-write');
|
|
224
|
+
offset += wrote;
|
|
225
|
+
}
|
|
226
|
+
fs.ftruncateSync(fd, out.length);
|
|
227
|
+
// The reported success has to mean the contract survives a crash: this file
|
|
228
|
+
// IS the contract, and a lost write leaves the project with no loadable one.
|
|
229
|
+
try {
|
|
230
|
+
fs.fsyncSync(fd);
|
|
231
|
+
} catch {
|
|
232
|
+
// Some filesystems refuse fsync on a regular file (rare, but real). The
|
|
233
|
+
// bytes are written either way; not durable is not a reason to report the
|
|
234
|
+
// write as failed.
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, file: relative, reason: result.reason };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return fail(error instanceof Error ? error.message : 'unwritable');
|
|
239
|
+
} finally {
|
|
240
|
+
try {
|
|
241
|
+
fs.closeSync(fd);
|
|
242
|
+
} catch {
|
|
243
|
+
/* already closed */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/sensorPromotion.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/sensor-promotion.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ARK_RULE_SENSORS, ARK_RULE_TIER2_SENSORS } from './arkrules-contract.mjs';
|
|
12
|
+
import { canPromoteInvariant } from './invariant-coverage.mjs';
|
|
13
|
+
import { ARKRUN_TIER1_SENSOR_IDS } from './ark-run-sensors.mjs';
|
|
14
|
+
import { ARKORDER_TIER1_SENSOR_IDS } from './ark-order-sensors.mjs';
|
|
15
|
+
/**
|
|
16
|
+
* The Tier-2 heuristic on the ArkRun plane (ADR 0022 D2).
|
|
17
|
+
*
|
|
18
|
+
* It is not in `ARKRUN_TIER1_SENSOR_IDS` because it is not evaluated at all —
|
|
19
|
+
* `arkRunSensors.ts` says so in its header. Listing it here is the whole point
|
|
20
|
+
* of this surface: a sensor you cannot promote should appear in the map, not go
|
|
21
|
+
* missing from it, or the reader concludes it does not exist rather than that
|
|
22
|
+
* it can never bite.
|
|
23
|
+
*/
|
|
24
|
+
const ARKRUN_TIER2_SENSOR_IDS = ['arkrun-skip-resolve'];
|
|
25
|
+
/**
|
|
26
|
+
* `invariant-coverage` is in the closed vocabulary and the schema will accept
|
|
27
|
+
* `mode: "enforced"` on a structure entry that names it, but
|
|
28
|
+
* `evaluateArkRuleSensors` has no case that emits for it: coverage is judged
|
|
29
|
+
* per invariant by the AR10 pass. Enforcing the structure entry buys no sensor
|
|
30
|
+
* findings, and reporting it as "promotable" would sell a tooth that does not
|
|
31
|
+
* exist.
|
|
32
|
+
*
|
|
33
|
+
* One sensor-independent effect survives promotion and the reason text says so:
|
|
34
|
+
* `collectEmptyAppliesToFindings` raises `ARKRULE_SCOPE_EMPTY` for ANY structure
|
|
35
|
+
* rule whose `appliesTo` matches zero governed files, and enforced makes that
|
|
36
|
+
* fail strict. That is a misconfiguration signal, not the coverage tooth the
|
|
37
|
+
* author was reaching for.
|
|
38
|
+
*/
|
|
39
|
+
const NO_TEETH_SENSORS = ['invariant-coverage'];
|
|
40
|
+
function planeOf(sensor) {
|
|
41
|
+
if (ARK_RULE_SENSORS.includes(sensor))
|
|
42
|
+
return 'arkrules';
|
|
43
|
+
if (ARKRUN_TIER1_SENSOR_IDS.includes(sensor) ||
|
|
44
|
+
ARKRUN_TIER2_SENSOR_IDS.includes(sensor)) {
|
|
45
|
+
return 'arkrun';
|
|
46
|
+
}
|
|
47
|
+
if (ARKORDER_TIER1_SENSOR_IDS.includes(sensor))
|
|
48
|
+
return 'arkorder';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function isTier2(sensor) {
|
|
52
|
+
return (ARK_RULE_TIER2_SENSORS.includes(sensor) ||
|
|
53
|
+
ARKRUN_TIER2_SENSOR_IDS.includes(sensor));
|
|
54
|
+
}
|
|
55
|
+
const TIER2_ADR = {
|
|
56
|
+
arkrules: 'ADR 0013',
|
|
57
|
+
arkrun: 'ADR 0022',
|
|
58
|
+
arkorder: 'ADR 0029',
|
|
59
|
+
};
|
|
60
|
+
/** The promotability of one sensor id, known before any rule is written. */
|
|
61
|
+
export function describeSensor(sensor) {
|
|
62
|
+
const plane = planeOf(sensor);
|
|
63
|
+
if (plane === null) {
|
|
64
|
+
return {
|
|
65
|
+
sensor,
|
|
66
|
+
// Not 'arkrules' with tier 1: a fabricated plane files an unknown id
|
|
67
|
+
// under a real one, and tier 1 reads as "direct evidence".
|
|
68
|
+
plane: 'unknown',
|
|
69
|
+
tier: 2,
|
|
70
|
+
promotable: false,
|
|
71
|
+
blocker: 'unknown-sensor',
|
|
72
|
+
reason: `Sensor ${JSON.stringify(sensor)} is in no closed sensor vocabulary; the loader rejects it.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (isTier2(sensor)) {
|
|
76
|
+
return {
|
|
77
|
+
sensor,
|
|
78
|
+
plane,
|
|
79
|
+
tier: 2,
|
|
80
|
+
promotable: false,
|
|
81
|
+
blocker: 'tier-2-advisory-only',
|
|
82
|
+
reason: `Sensor ${JSON.stringify(sensor)} is Tier-2 (${TIER2_ADR[plane]}): a heuristic, advisory forever. No rule on this sensor can ever be promoted to enforced.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (NO_TEETH_SENSORS.includes(sensor)) {
|
|
86
|
+
return {
|
|
87
|
+
sensor,
|
|
88
|
+
plane,
|
|
89
|
+
tier: 1,
|
|
90
|
+
promotable: false,
|
|
91
|
+
blocker: 'no-structure-teeth',
|
|
92
|
+
reason: `Sensor ${JSON.stringify(sensor)} emits no structure violations — invariant coverage is judged per entry in "invariants", not by a structure rule — so enforcing a structure rule on it buys no coverage tooth (only a zero-match "appliesTo" would still fail). Promote the invariant entry instead.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
sensor,
|
|
97
|
+
plane,
|
|
98
|
+
tier: 1,
|
|
99
|
+
promotable: true,
|
|
100
|
+
blocker: null,
|
|
101
|
+
// Naming the mechanism, not just the verdict. Only the ArkRules plane is
|
|
102
|
+
// promoted per rule; ArkRun and ArkOrder are switched by one plane-level
|
|
103
|
+
// `mode`, and `--promote --apply` writes ArkRules documents only. Saying
|
|
104
|
+
// "can be enforced" for all three would answer the question in a currency
|
|
105
|
+
// this surface cannot spend.
|
|
106
|
+
reason: plane === 'arkrules'
|
|
107
|
+
? `Sensor ${JSON.stringify(sensor)} is Tier-1 (direct evidence) and can be enforced: set mode "enforced" on a rule that names it.`
|
|
108
|
+
: `Sensor ${JSON.stringify(sensor)} is Tier-1 (direct evidence) and can be enforced, but via the plane switch \`${plane}.mode\` in ark.config.json — not per rule, and not by --promote --apply.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Every sensor ArkGate ships, across all three planes, in declaration order.
|
|
113
|
+
*
|
|
114
|
+
* On the field repository almost every declared rule turned out to be
|
|
115
|
+
* promotable and nobody knew, because the only way to find out was to try one
|
|
116
|
+
* at a time. The whole vocabulary is a constant: printing it costs nothing and
|
|
117
|
+
* answers the question before the first attempt. The shipped counts are
|
|
118
|
+
* asserted in tests/unit/domain/sensorPromotion.test.ts, so this comment can
|
|
119
|
+
* never drift into a number the code does not produce.
|
|
120
|
+
*/
|
|
121
|
+
export function sensorVocabulary() {
|
|
122
|
+
const ids = [
|
|
123
|
+
...ARK_RULE_SENSORS,
|
|
124
|
+
...ARKRUN_TIER1_SENSOR_IDS,
|
|
125
|
+
...ARKRUN_TIER2_SENSOR_IDS,
|
|
126
|
+
...ARKORDER_TIER1_SENSOR_IDS,
|
|
127
|
+
];
|
|
128
|
+
return ids.map((sensor) => describeSensor(sensor));
|
|
129
|
+
}
|
|
130
|
+
function nullable(value) {
|
|
131
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The identity of a rule, spelled the way the author wrote it.
|
|
135
|
+
*
|
|
136
|
+
* The Tier-2 rejection names the sensor and nothing else, so an author whose
|
|
137
|
+
* rule is called `types-only` reads an error about `no-anemic-model` and has to
|
|
138
|
+
* work out that the two are the same thing. Every reason string built here
|
|
139
|
+
* leads with the local id and names the file it came from.
|
|
140
|
+
*/
|
|
141
|
+
function locate(id, sourceFile) {
|
|
142
|
+
return sourceFile ? `Rule ${JSON.stringify(id)} (declared in ${sourceFile})` : `Rule ${JSON.stringify(id)}`;
|
|
143
|
+
}
|
|
144
|
+
export function buildSensorMap(input) {
|
|
145
|
+
const declaredStructure = input.structure ?? [];
|
|
146
|
+
const declaredInvariants = input.invariants ?? [];
|
|
147
|
+
// One pass to learn which ids are shared before any row is built: a row that
|
|
148
|
+
// does not know it is one of two cannot warn anybody.
|
|
149
|
+
const idCounts = new Map();
|
|
150
|
+
for (const rule of [...declaredStructure, ...declaredInvariants]) {
|
|
151
|
+
idCounts.set(rule.id, (idCounts.get(rule.id) ?? 0) + 1);
|
|
152
|
+
}
|
|
153
|
+
const ambiguity = (id) => {
|
|
154
|
+
const declarationsWithThisId = idCounts.get(id) ?? 1;
|
|
155
|
+
return { declarationsWithThisId, ambiguousId: declarationsWithThisId > 1 };
|
|
156
|
+
};
|
|
157
|
+
const structure = declaredStructure.map((rule) => {
|
|
158
|
+
const sourceFile = nullable(rule.sourceFile);
|
|
159
|
+
const sensor = describeSensor(rule.sensor);
|
|
160
|
+
const reason = sensor.promotable
|
|
161
|
+
? `${locate(rule.id, sourceFile)} delegates to sensor ${JSON.stringify(rule.sensor)}, which is Tier-1 (direct evidence) and can be enforced.`
|
|
162
|
+
: `${locate(rule.id, sourceFile)} delegates to sensor ${JSON.stringify(rule.sensor)}. ${sensor.reason}`;
|
|
163
|
+
return {
|
|
164
|
+
kind: 'structure',
|
|
165
|
+
id: rule.id,
|
|
166
|
+
sensor: rule.sensor,
|
|
167
|
+
tier: sensor.tier,
|
|
168
|
+
mode: rule.mode,
|
|
169
|
+
layer: nullable(rule.layer),
|
|
170
|
+
sourceFile,
|
|
171
|
+
description: nullable(rule.description),
|
|
172
|
+
promotable: sensor.promotable,
|
|
173
|
+
blocker: sensor.blocker,
|
|
174
|
+
reason,
|
|
175
|
+
...ambiguity(rule.id),
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
const invariants = declaredInvariants.map((rule) => {
|
|
179
|
+
const sourceFile = nullable(rule.sourceFile);
|
|
180
|
+
// One judge for promotion, shared with the policy-delta gate: a second
|
|
181
|
+
// opinion here would let this surface promise what the gate then refuses.
|
|
182
|
+
const verdict = canPromoteInvariant(rule.coverage);
|
|
183
|
+
return {
|
|
184
|
+
kind: 'invariant',
|
|
185
|
+
id: rule.id,
|
|
186
|
+
mode: rule.mode,
|
|
187
|
+
layer: nullable(rule.layer),
|
|
188
|
+
sourceFile,
|
|
189
|
+
description: nullable(rule.description),
|
|
190
|
+
promotable: verdict.ok,
|
|
191
|
+
blocker: verdict.ok ? null : 'no-coverage-evidence',
|
|
192
|
+
reason: `${locate(rule.id, sourceFile)}: ${verdict.reason}`,
|
|
193
|
+
coverageEvaluated: rule.coverage != null,
|
|
194
|
+
...ambiguity(rule.id),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
const all = [...structure, ...invariants];
|
|
198
|
+
return {
|
|
199
|
+
vocabulary: sensorVocabulary(),
|
|
200
|
+
structure,
|
|
201
|
+
invariants,
|
|
202
|
+
totals: {
|
|
203
|
+
declared: all.length,
|
|
204
|
+
enforced: all.filter((row) => row.mode === 'enforced').length,
|
|
205
|
+
advisoryPromotable: all.filter((row) => row.mode === 'advisory' && row.promotable).length,
|
|
206
|
+
advisoryBlocked: all.filter((row) => row.mode === 'advisory' && !row.promotable).length,
|
|
207
|
+
ambiguousIds: [...idCounts.values()].filter((count) => count > 1).length,
|
|
208
|
+
},
|
|
209
|
+
notAScore: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Ids close enough to be what the caller meant: a substring either way, so a
|
|
214
|
+
* typo'd `types-onl` and an over-qualified `domain/types-only` both land.
|
|
215
|
+
*/
|
|
216
|
+
function suggestionsFor(focus, ids) {
|
|
217
|
+
const needle = focus.toLowerCase();
|
|
218
|
+
const near = ids.filter((id) => {
|
|
219
|
+
const hay = id.toLowerCase();
|
|
220
|
+
return hay.includes(needle) || needle.includes(hay);
|
|
221
|
+
});
|
|
222
|
+
return (near.length > 0 ? near : [...ids]).slice(0, 10);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The key a finding is counted under: the document that declared the rule plus
|
|
226
|
+
* the rule id. Both are stamped on every ArkRules finding (`arkruleSource` /
|
|
227
|
+
* `arkruleId`), so this is the identity the gate itself uses.
|
|
228
|
+
*/
|
|
229
|
+
export function ruleCountKey(sourceFile, ruleId) {
|
|
230
|
+
return `${sourceFile ?? ''}#${ruleId}`;
|
|
231
|
+
}
|
|
232
|
+
export function buildPromotionPreview(input) {
|
|
233
|
+
const counts = input.countsByRuleKey ?? {};
|
|
234
|
+
const analysis = input.analysis ?? {};
|
|
235
|
+
const complete = analysis.completeness === undefined || analysis.completeness === 'complete';
|
|
236
|
+
const countsTrustworthy = complete && analysis.teethDemotedByFloor !== true && analysis.scopeNarrowed !== true;
|
|
237
|
+
const all = [...input.map.structure, ...input.map.invariants];
|
|
238
|
+
const focus = typeof input.focus === 'string' && input.focus.length > 0 ? input.focus : null;
|
|
239
|
+
const selected = focus ? all.filter((row) => row.id === focus) : all;
|
|
240
|
+
const unknownFocus = focus != null && selected.length === 0;
|
|
241
|
+
const rows = selected.map((row) => {
|
|
242
|
+
const key = ruleCountKey(row.sourceFile, row.id);
|
|
243
|
+
const currentFindings = Number.isFinite(counts[key]) ? Number(counts[key]) : 0;
|
|
244
|
+
// The floor demotes every enforced extra-plane finding to a warning, so a
|
|
245
|
+
// promotion made under it buys a label, not a tooth. Pricing it at N
|
|
246
|
+
// "would start failing the gate" would be a promise the gate does not keep.
|
|
247
|
+
const canBite = analysis.teethDemotedByFloor !== true;
|
|
248
|
+
const wouldBlock = row.mode === 'advisory' && row.promotable && canBite ? currentFindings : 0;
|
|
249
|
+
return {
|
|
250
|
+
...row,
|
|
251
|
+
currentFindings,
|
|
252
|
+
wouldBlock,
|
|
253
|
+
// ambiguousId no longer poisons the COUNT (the key is per document), but
|
|
254
|
+
// it still makes a write ambiguous, which applyPromotion refuses.
|
|
255
|
+
countIsUnreliable: !complete || analysis.scopeNarrowed === true,
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
focus,
|
|
260
|
+
unknownFocus,
|
|
261
|
+
ambiguousFocus: focus != null && selected.length > 1,
|
|
262
|
+
suggestions: unknownFocus ? suggestionsFor(focus, all.map((row) => row.id)) : [],
|
|
263
|
+
rows,
|
|
264
|
+
analysis,
|
|
265
|
+
countsTrustworthy,
|
|
266
|
+
totals: {
|
|
267
|
+
rules: rows.length,
|
|
268
|
+
wouldBlock: rows.reduce((sum, row) => sum + row.wouldBlock, 0),
|
|
269
|
+
cleanPromotions: rows.filter((row) => row.mode === 'advisory' &&
|
|
270
|
+
row.promotable &&
|
|
271
|
+
row.currentFindings === 0 &&
|
|
272
|
+
!row.countIsUnreliable).length,
|
|
273
|
+
},
|
|
274
|
+
notAScore: true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Indentation the author used, so an indented document keeps its shape.
|
|
279
|
+
*
|
|
280
|
+
* A minified single-line document has none to detect and comes back
|
|
281
|
+
* pretty-printed at two spaces: the write is a JSON round-trip, not a targeted
|
|
282
|
+
* text edit, so "the file is untouched apart from one field" is only true of a
|
|
283
|
+
* document that was already indented.
|
|
284
|
+
*/
|
|
285
|
+
function detectIndent(text) {
|
|
286
|
+
const match = /\n([ \t]+)"/.exec(text);
|
|
287
|
+
if (!match)
|
|
288
|
+
return 2;
|
|
289
|
+
const found = match[1];
|
|
290
|
+
return found.includes('\t') ? '\t' : found.length;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Set `mode: "enforced"` on one rule inside an ArkRules document.
|
|
294
|
+
*
|
|
295
|
+
* Pure text in, pure text out: the caller owns the descriptor and the
|
|
296
|
+
* containment checks. The write refuses anything it cannot do exactly — an
|
|
297
|
+
* unparseable document, an id that is not there, an id that appears twice (the
|
|
298
|
+
* contract rejects duplicates, so this only fires on a file that was never
|
|
299
|
+
* loaded) — because a half-applied promotion is worse than none.
|
|
300
|
+
*/
|
|
301
|
+
export function promoteRuleInArkRulesText(text, ruleId,
|
|
302
|
+
/**
|
|
303
|
+
* The sensor this rule had when its promotion was priced. The file is read
|
|
304
|
+
* again here, so a concurrent edit between the preview and the write could
|
|
305
|
+
* have turned a Tier-1 rule into a Tier-2 one — and writing `enforced` onto
|
|
306
|
+
* that produces a contract the loader then refuses, while the command reports
|
|
307
|
+
* success. Passing it binds the write to the rule that was actually judged.
|
|
308
|
+
*/
|
|
309
|
+
expectedSensor) {
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = JSON.parse(text);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: `ArkRules document is not valid JSON (${error instanceof Error ? error.message : String(error)}).`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
321
|
+
return { ok: false, reason: 'ArkRules document is not a JSON object.' };
|
|
322
|
+
}
|
|
323
|
+
const doc = parsed;
|
|
324
|
+
const matches = [];
|
|
325
|
+
for (const key of ['structure', 'invariants']) {
|
|
326
|
+
const list = doc[key];
|
|
327
|
+
if (!Array.isArray(list))
|
|
328
|
+
continue;
|
|
329
|
+
for (const entry of list) {
|
|
330
|
+
if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
331
|
+
if (entry.id === ruleId) {
|
|
332
|
+
matches.push(entry);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (matches.length === 0) {
|
|
338
|
+
return { ok: false, reason: `Rule ${JSON.stringify(ruleId)} is not declared in this file.` };
|
|
339
|
+
}
|
|
340
|
+
if (matches.length > 1) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
reason: `Rule ${JSON.stringify(ruleId)} appears ${matches.length} times in this file; refusing to guess which one to promote.`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const entry = matches[0];
|
|
347
|
+
if (expectedSensor !== undefined && entry.sensor !== expectedSensor) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: `Rule ${JSON.stringify(ruleId)} now uses sensor ${JSON.stringify(entry.sensor)}, not the ${JSON.stringify(expectedSensor)} its promotion was judged against — the file changed since the preview. Re-run the preview.`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (entry.mode === 'enforced') {
|
|
354
|
+
return { ok: false, reason: `Rule ${JSON.stringify(ruleId)} is already enforced.` };
|
|
355
|
+
}
|
|
356
|
+
entry.mode = 'enforced';
|
|
357
|
+
const trailingNewline = text.endsWith('\n') ? '\n' : '';
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
360
|
+
text: `${JSON.stringify(doc, null, detectIndent(text))}${trailingNewline}`,
|
|
361
|
+
reason: `Rule ${JSON.stringify(ruleId)} set to mode "enforced".`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -39,6 +39,14 @@ type ArkConfigRule = {
|
|
|
39
39
|
message?: string;
|
|
40
40
|
peerIsolation?: boolean;
|
|
41
41
|
sliceFolders?: string[];
|
|
42
|
+
/** Roots the repo declares shared on purpose — evidence, not unclassifiable. */
|
|
43
|
+
sharedRoots?: string[];
|
|
44
|
+
/** Directed slice→slice edges the repo declares on purpose. */
|
|
45
|
+
allowedCrossSlice?: ArkConfigCrossSliceEdge[];
|
|
46
|
+
};
|
|
47
|
+
type ArkConfigCrossSliceEdge = {
|
|
48
|
+
from: string;
|
|
49
|
+
to: string;
|
|
42
50
|
};
|
|
43
51
|
type ArkConfigSafety = {
|
|
44
52
|
maxTsSuppressions?: number;
|
|
@@ -46,6 +54,24 @@ type ArkConfigSafety = {
|
|
|
46
54
|
allowInMemory?: boolean;
|
|
47
55
|
allowDisabledPeerIsolation?: boolean;
|
|
48
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Optional invariant-coverage scan controls. Absence keeps the built-in
|
|
59
|
+
* defaults (test-name heuristic, 400-file budget) and changes no verdict.
|
|
60
|
+
*/
|
|
61
|
+
type ArkConfigCoverage = {
|
|
62
|
+
/** Globs that decide which files count as tests (replaces the name heuristic). */
|
|
63
|
+
testGlobs?: string[];
|
|
64
|
+
/** Max files loaded as coverage evidence before the budget is exhausted. */
|
|
65
|
+
maxFiles?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Path prefixes where this project declares its test runner actually executes
|
|
68
|
+
* tests. ArkGate never runs anything: this is a second declaration to compare
|
|
69
|
+
* the coverage scan against, so a covering test found outside them is reported
|
|
70
|
+
* (INVARIANT_COVERAGE_OUTSIDE_ROOTS) instead of silently certifying the
|
|
71
|
+
* invariant. Absence means no declaration and no such claim.
|
|
72
|
+
*/
|
|
73
|
+
coverageRoots?: string[];
|
|
74
|
+
};
|
|
49
75
|
/**
|
|
50
76
|
* ADR 0012 — optional map of layer name → project-relative ArkRules file path.
|
|
51
77
|
* Absence changes no inter-layer verdict.
|
|
@@ -76,6 +102,11 @@ type ArkConfigArkOrder = {
|
|
|
76
102
|
planeRoots: string[];
|
|
77
103
|
managedLayers: string[];
|
|
78
104
|
maxXiKeys: number;
|
|
105
|
+
/**
|
|
106
|
+
* Slow product keys the team can already name (plan, cost code, protocol).
|
|
107
|
+
* Optional. Empty → `ARKORDER_XI_FIELD_WRITE` stays silent.
|
|
108
|
+
*/
|
|
109
|
+
xiKeys: string[];
|
|
79
110
|
};
|
|
80
111
|
type ArkConfig = {
|
|
81
112
|
$schema: string;
|
|
@@ -90,6 +121,11 @@ type ArkConfig = {
|
|
|
90
121
|
cyclePolicy?: ArkConfigCyclePolicy;
|
|
91
122
|
dynamicImportAllowlist?: string[];
|
|
92
123
|
safety?: ArkConfigSafety;
|
|
124
|
+
/**
|
|
125
|
+
* Invariant-coverage scan controls (test globs + file budget).
|
|
126
|
+
* Absence keeps the defaults; it never turns coverage on by itself.
|
|
127
|
+
*/
|
|
128
|
+
coverage?: ArkConfigCoverage;
|
|
93
129
|
/** ADR 0012 — modular ArkRules references (schema 1.1+). */
|
|
94
130
|
arkRules?: ArkConfigArkRulesRefs;
|
|
95
131
|
/** ADR 0020 — optional ArkRun extra (schema 1.2+). Absence changes no Layers/ArkRules verdict. */
|