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.
Files changed (73) hide show
  1. package/CHANGELOG.md +257 -3
  2. package/README.md +47 -9
  3. package/bin/ark-check-runtime.mjs +340 -5
  4. package/bin/ark-layer-match.mjs +170 -13
  5. package/bin/ark-mcp-runtime.mjs +9 -2
  6. package/bin/lib/analysis-completeness.mjs +86 -0
  7. package/bin/lib/analysis-engine.mjs +6 -6
  8. package/bin/lib/architecture-scan.mjs +2 -0
  9. package/bin/lib/ark-order-facts.mjs +59 -0
  10. package/bin/lib/ark-order-sensors.mjs +31 -2
  11. package/bin/lib/arkrule-file-hints.mjs +6 -2
  12. package/bin/lib/arkrules-contract.mjs +9 -1
  13. package/bin/lib/arkrules-sensors.mjs +22 -2
  14. package/bin/lib/check-args.mjs +66 -0
  15. package/bin/lib/config-contract.mjs +26 -0
  16. package/bin/lib/config-extras.mjs +2 -0
  17. package/bin/lib/design-smells.mjs +85 -0
  18. package/bin/lib/diagnostic-catalog.mjs +8 -2
  19. package/bin/lib/first-run-help.mjs +12 -0
  20. package/bin/lib/invariant-coverage-io.mjs +175 -19
  21. package/bin/lib/invariant-coverage.mjs +110 -7
  22. package/bin/lib/literal-path-drift-io.mjs +569 -0
  23. package/bin/lib/literal-path-drift.mjs +761 -0
  24. package/bin/lib/policy-delta-io.mjs +5 -0
  25. package/bin/lib/remediation.mjs +24 -1
  26. package/bin/lib/resolved-candidate-facts.mjs +31 -0
  27. package/bin/lib/rules-under-contract.mjs +5 -0
  28. package/bin/lib/scan-files.mjs +54 -0
  29. package/bin/lib/sensor-promote-cli.mjs +372 -0
  30. package/bin/lib/sensor-promote-io.mjs +246 -0
  31. package/bin/lib/sensor-promotion.mjs +363 -0
  32. package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
  33. package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
  34. package/dist/eslint/index.cjs +5 -5
  35. package/dist/eslint/index.d.ts +34 -1
  36. package/dist/eslint/index.js +5 -5
  37. package/dist/index.cjs +31 -31
  38. package/dist/index.d.ts +85 -7
  39. package/dist/index.js +31 -31
  40. package/dist/nestjs/index.cjs +5 -5
  41. package/dist/nestjs/index.d.ts +3 -3
  42. package/dist/nestjs/index.js +5 -5
  43. package/dist/runtime/index.cjs +13 -13
  44. package/dist/runtime/index.d.ts +6 -6
  45. package/dist/runtime/index.js +13 -13
  46. package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
  47. package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
  48. package/docs/README.md +4 -4
  49. package/docs/agent-guide.md +182 -0
  50. package/docs/configuration.md +89 -9
  51. package/docs/develop.md +24 -2
  52. package/docs/diagnostics.md +79 -1
  53. package/docs/enthusiast/README.md +6 -4
  54. package/docs/package-surface.md +36 -4
  55. package/docs/product-voice.md +15 -5
  56. package/docs/use.md +8 -5
  57. package/package.json +2 -2
  58. package/schemas/ark.arkrules.schema.json +1 -0
  59. package/schemas/ark.config.schema.json +72 -0
  60. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  61. package/server.json +3 -3
  62. package/templates/agent-skills/README.md +1 -1
  63. package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
  64. package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
  65. package/templates/agent-skills/ark-contract/SKILL.md +4 -0
  66. package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
  67. package/templates/agent-skills/ark-place/SKILL.md +6 -2
  68. package/templates/arkrules/ApplicationOrchestration.json +6 -0
  69. package/templates/skills/ark-adopt.md +13 -3
  70. package/templates/skills/ark-autopilot.md +1 -1
  71. package/templates/skills/ark-contract.md +4 -0
  72. package/templates/skills/ark-coverage.md +1 -0
  73. 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. */