arkgate 4.6.6 → 4.7.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +139 -2
  2. package/README.md +22 -10
  3. package/SECURITY.md +1 -1
  4. package/bin/ark-check-runtime.mjs +21 -341
  5. package/bin/ark-mcp-runtime.mjs +71 -325
  6. package/bin/ark-shared.mjs +24 -158
  7. package/bin/lib/adapter-contract.mjs +17 -36
  8. package/bin/lib/analysis-engine.mjs +6 -6
  9. package/bin/lib/ark-run-doctor.mjs +144 -0
  10. package/bin/lib/ark-run-facts.mjs +472 -0
  11. package/bin/lib/ark-run-report.mjs +57 -0
  12. package/bin/lib/ark-run-sensors.mjs +309 -0
  13. package/bin/lib/check-args.mjs +173 -0
  14. package/bin/lib/check-config-detect.mjs +101 -0
  15. package/bin/lib/check-watch.mjs +80 -0
  16. package/bin/lib/config-contract.mjs +86 -11
  17. package/bin/lib/deep-module-coach.mjs +3 -0
  18. package/bin/lib/diagnostic-catalog.mjs +8 -0
  19. package/bin/lib/doctor-advisories.mjs +45 -8
  20. package/bin/lib/doctor-human.mjs +519 -0
  21. package/bin/lib/doctor-plan.mjs +62 -445
  22. package/bin/lib/extra-merge-teeth.mjs +187 -0
  23. package/bin/lib/github-enforcement.mjs +22 -9
  24. package/bin/lib/html-report-advisories.mjs +2 -0
  25. package/bin/lib/html-report-depth.mjs +22 -2
  26. package/bin/lib/html-report.mjs +40 -7
  27. package/bin/lib/mcp-hook-payload.mjs +328 -0
  28. package/bin/lib/package-manager.mjs +174 -0
  29. package/bin/lib/policy-delta-io.mjs +4 -0
  30. package/bin/lib/remediation.mjs +132 -0
  31. package/bin/lib/resolved-candidate-facts.mjs +67 -2
  32. package/bin/lib/rules-under-contract.mjs +37 -89
  33. package/bin/lib/snippet-analysis.mjs +43 -2
  34. package/bin/lib/status-command.mjs +28 -0
  35. package/bin/lib/status-manifest.mjs +23 -0
  36. package/bin/lib/team-parliament-io.mjs +4 -0
  37. package/dist/{configTypes-l6XiwiC1.d.ts → configTypes-CgJimx9o.d.ts} +17 -3
  38. package/dist/eslint/index.cjs +6 -2
  39. package/dist/eslint/index.d.ts +70 -2
  40. package/dist/eslint/index.js +6 -2
  41. package/dist/index.cjs +35 -35
  42. package/dist/index.d.ts +787 -272
  43. package/dist/index.js +35 -35
  44. package/docs/README.md +4 -3
  45. package/docs/agent-guide.md +21 -15
  46. package/docs/ai-gates.md +13 -0
  47. package/docs/configuration.md +24 -11
  48. package/docs/develop.md +12 -3
  49. package/docs/diagnostics.md +75 -0
  50. package/docs/enthusiast/README.md +4 -3
  51. package/docs/package-surface.md +17 -13
  52. package/docs/product-voice.md +6 -3
  53. package/docs/threat-model.md +1 -1
  54. package/docs/use.md +5 -4
  55. package/package.json +1 -1
  56. package/schemas/ark.config.schema.json +41 -2
  57. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  58. package/schemas/ark.status-manifest.schema.json +47 -0
  59. package/server.json +2 -2
  60. package/templates/agent-skills/README.md +1 -1
  61. package/templates/agent-skills/ark-adopt/SKILL.md +23 -2
  62. package/templates/agent-skills/ark-place/SKILL.md +26 -2
  63. package/templates/agent-skills/ark-runtime/SKILL.md +66 -24
  64. package/templates/skills/ark-adopt.md +23 -2
  65. package/templates/skills/ark-place.md +26 -2
  66. package/templates/skills/ark-runtime.md +66 -24
@@ -0,0 +1,187 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/extraMergeTeeth.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/extra-merge-teeth.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const EXTRA_MERGE_TEETH_GOVERNED_FLOOR = 50;
12
+ export function normalizeExtraMergeTeethClassification(classification) {
13
+ if (!classification)
14
+ return {};
15
+ const governedPercent = typeof classification.governedPercent === 'number' ? classification.governedPercent : null;
16
+ let populatedLayerCount = typeof classification.populatedLayerCount === 'number'
17
+ ? classification.populatedLayerCount
18
+ : null;
19
+ if (populatedLayerCount == null &&
20
+ typeof classification.classifiedFiles === 'number') {
21
+ populatedLayerCount = classification.classifiedFiles > 0 ? 1 : 0;
22
+ }
23
+ return { governedPercent, populatedLayerCount };
24
+ }
25
+ export function extraMergeTeethAllowed(classification) {
26
+ const normalized = normalizeExtraMergeTeethClassification(classification);
27
+ const governed = typeof normalized.governedPercent === 'number' ? normalized.governedPercent : null;
28
+ const populated = typeof normalized.populatedLayerCount === 'number' ? normalized.populatedLayerCount : null;
29
+ if (governed == null && populated == null)
30
+ return true;
31
+ return ((governed ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR && (populated ?? 0) >= 1);
32
+ }
33
+ export function classifyResolvedLayerCoverage(files) {
34
+ const total = files.length;
35
+ let classified = 0;
36
+ const populated = new Set();
37
+ for (const file of files) {
38
+ const layer = typeof file.layer === 'string' && file.layer.length > 0 ? file.layer : null;
39
+ if (!layer)
40
+ continue;
41
+ classified += 1;
42
+ populated.add(layer);
43
+ }
44
+ return {
45
+ governedPercent: total > 0 ? Math.round((classified / total) * 100) : 0,
46
+ populatedLayerCount: populated.size,
47
+ };
48
+ }
49
+ export function isArkRunRuleId(ruleId) {
50
+ return typeof ruleId === 'string' && ruleId.startsWith('ARKRUN_');
51
+ }
52
+ export function isExtraPlaneFinding(violation) {
53
+ if (violation?.arkruleId != null)
54
+ return true;
55
+ const id = typeof violation?.ruleId === 'string' ? violation.ruleId : '';
56
+ return id.startsWith('ARKRULE') || id.startsWith('arkrule') || id.startsWith('ARKRUN_');
57
+ }
58
+ /**
59
+ * Under the classification floor, demote enforced extra-plane findings in place
60
+ * so merge/write/CI match (layer graph only). Unknown classification is a no-op.
61
+ */
62
+ export function demoteExtraPlaneTeethUnderClassificationFloor(violations, classification = {}) {
63
+ if (!Array.isArray(violations))
64
+ return violations;
65
+ if (extraMergeTeethAllowed(classification))
66
+ return violations;
67
+ for (const violation of violations) {
68
+ if (isExtraPlaneFinding(violation) && violation.failsStrict !== false) {
69
+ violation.failsStrict = false;
70
+ if (violation.severity === 'error')
71
+ violation.severity = 'warning';
72
+ }
73
+ }
74
+ return violations;
75
+ }
76
+ /** Stamp for extra-plane honesty: never one architecture score. */
77
+ export const MERGE_PLANES_DUAL_STAMP = 'Structure = heuristics; invariants = catalog+coverage evidence (not business runtime); ArkRun = kernel usage + declarations (not a score). Extra planes never merge into one architecture score. Advisory ArkRules ≠ merge teeth. Advisory ArkRun ≠ merge teeth.';
78
+ function countOrZero(value) {
79
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
80
+ }
81
+ /**
82
+ * Which extra planes can fail merge. Counts and stamps only — never a score.
83
+ */
84
+ export function composeMergePlanesHonesty(input = {}) {
85
+ const normalized = normalizeExtraMergeTeethClassification(input.classification);
86
+ const governedPercent = typeof normalized.governedPercent === 'number' ? normalized.governedPercent : null;
87
+ const populatedLayerCount = typeof normalized.populatedLayerCount === 'number' ? normalized.populatedLayerCount : null;
88
+ const classificationKnown = governedPercent != null || populatedLayerCount != null;
89
+ const classificationAllowsTeeth = extraMergeTeethAllowed(normalized);
90
+ const arkRules = input.arkRules;
91
+ const structureEnforced = countOrZero(arkRules?.structureEnforced);
92
+ const structureTotal = countOrZero(arkRules?.structureTotal);
93
+ const structureAdvisory = typeof arkRules?.structureAdvisory === 'number'
94
+ ? countOrZero(arkRules.structureAdvisory)
95
+ : Math.max(0, structureTotal - structureEnforced);
96
+ const invariantEnforced = countOrZero(arkRules?.invariantEnforced);
97
+ const invariantTotal = countOrZero(arkRules?.invariantTotal);
98
+ const invariantAdvisory = typeof arkRules?.invariantAdvisory === 'number'
99
+ ? countOrZero(arkRules.invariantAdvisory)
100
+ : Math.max(0, invariantTotal - invariantEnforced);
101
+ const arkRulesHasEnforced = structureEnforced > 0 || invariantEnforced > 0;
102
+ const arkRunPresent = input.arkRun?.present === true;
103
+ const arkRunMode = input.arkRun?.mode === 'enforced' || input.arkRun?.mode === 'advisory'
104
+ ? input.arkRun.mode
105
+ : null;
106
+ const arkRunResidual = countOrZero(input.arkRun?.residualCount);
107
+ const arkRunHasEnforced = arkRunPresent && arkRunMode === 'enforced';
108
+ const arkRunTeeth = arkRunHasEnforced && classificationAllowsTeeth;
109
+ const hasEnforcedTeeth = arkRulesHasEnforced || arkRunHasEnforced;
110
+ const extraMergeTeeth = hasEnforcedTeeth && classificationAllowsTeeth;
111
+ const teethDeferredForClassification = hasEnforcedTeeth && classificationKnown && !classificationAllowsTeeth;
112
+ let failMergeWhen;
113
+ if (extraMergeTeeth) {
114
+ const extras = [];
115
+ if (arkRulesHasEnforced)
116
+ extras.push('enforced structure/invariant findings');
117
+ if (arkRunHasEnforced)
118
+ extras.push('enforced ArkRun skip findings');
119
+ failMergeWhen = `Layer graph failures plus ${extras.join(' and ')} (advisory extras never fail merge alone).`;
120
+ }
121
+ else if (teethDeferredForClassification) {
122
+ const which = [
123
+ arkRulesHasEnforced ? 'ArkRules structure/invariant' : null,
124
+ arkRunHasEnforced ? 'ArkRun' : null,
125
+ ]
126
+ .filter((part) => Boolean(part))
127
+ .join(' and ');
128
+ failMergeWhen = `Layer graph only — enforced ${which} findings are demoted under the teeth floor (need ≥${EXTRA_MERGE_TEETH_GOVERNED_FLOOR}% governed and ≥1 populated layer); they do not merge-block until classification is honest.`;
129
+ }
130
+ else {
131
+ const arkRunBit = !arkRunPresent
132
+ ? ' Absence of arkRun is silent.'
133
+ : arkRunMode === 'advisory'
134
+ ? ' Advisory ArkRun never merge-blocks.'
135
+ : ' ArkRun extra is present but does not arm merge teeth.';
136
+ failMergeWhen =
137
+ 'Layer graph only — no enforced ArkRules structure/invariant teeth on this tree. Advisory packs do not arm merge teeth.' +
138
+ arkRunBit;
139
+ }
140
+ const out = {
141
+ layers: {
142
+ role: 'inter-layer-edges',
143
+ alwaysOnGate: true,
144
+ note: 'Import/export layer graph — the default merge plane. Absent arkRules or arkRun changes nothing here.',
145
+ },
146
+ structureSensors: {
147
+ role: 'intra-layer-heuristics',
148
+ total: structureTotal,
149
+ enforced: structureEnforced,
150
+ advisory: structureAdvisory,
151
+ note: 'Structure sensors are heuristics (prefer false negatives). Only mode:enforced fails merge; noisy sensors stay advisory by default. Advisory-only packs never add merge teeth (FG-ARKRULES-ADVISORY-ONLY).',
152
+ },
153
+ invariants: {
154
+ role: 'catalog-plus-coverage',
155
+ total: invariantTotal,
156
+ enforced: invariantEnforced,
157
+ advisory: invariantAdvisory,
158
+ covered: countOrZero(arkRules?.covered),
159
+ uncovered: countOrZero(arkRules?.uncovered),
160
+ note: 'Invariants are catalog + coverage evidence, not a business runtime. Enforced + proven-uncovered fails merge; absence of enforced rules adds no extra teeth.',
161
+ },
162
+ arkRun: {
163
+ role: 'kernel-usage-and-declarations',
164
+ present: arkRunPresent,
165
+ mode: arkRunMode,
166
+ residualCount: arkRunResidual,
167
+ extraMergeTeeth: arkRunTeeth,
168
+ note: arkRunPresent
169
+ ? arkRunMode === 'enforced'
170
+ ? 'Enforced ArkRun arms extra merge teeth only when the layer plane is classified. Residual is a count, never a score.'
171
+ : 'Advisory ArkRun never adds merge teeth and never flips valid. Residual is a count, never a score.'
172
+ : 'Absence of arkRun is silent — Layers and ArkRules verdicts unchanged. The extra never becomes a score.',
173
+ },
174
+ extraMergeTeeth,
175
+ dualPlaneStamp: MERGE_PLANES_DUAL_STAMP,
176
+ failMergeWhen,
177
+ };
178
+ if (classificationKnown) {
179
+ out.classificationGate = {
180
+ governedPercent,
181
+ populatedLayerCount,
182
+ floorPercent: EXTRA_MERGE_TEETH_GOVERNED_FLOOR,
183
+ allowsTeeth: classificationAllowsTeeth,
184
+ };
185
+ }
186
+ return out;
187
+ }
@@ -3,6 +3,19 @@ import { spawnSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
6
+ /** Kill hung gh instead of stalling CI. */
7
+ export const SPAWN_TIMEOUT_MS = 8000;
8
+
9
+ function runGh(args, opts = {}) {
10
+ const { run, ...rest } = opts;
11
+ const spawn = typeof run === 'function' ? run : spawnSync;
12
+ return spawn('gh', args, {
13
+ encoding: 'utf8',
14
+ ...rest,
15
+ timeout: SPAWN_TIMEOUT_MS,
16
+ });
17
+ }
18
+
6
19
  const IF_LINE = /^[ \t]*(?:-\s+)?(?:"if"|'if'|if):\s*(.*?)\s*(?:#.*)?$/i;
7
20
  const CONTINUE_LINE = /^[ \t]*(?:-\s+)?(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*(.*?)\s*(?:#.*)?$/i;
8
21
  const SAFE_IF = /^(?:['"]?true['"]?|['"]?\$\{\{\s*(?:true|always\(\))\s*\}\}['"]?)$/i;
@@ -434,7 +447,7 @@ export function reportGithubCiRuntime(opts = {}) {
434
447
  const cwd = opts.cwd ?? process.cwd();
435
448
  const env = opts.env ?? process.env;
436
449
  const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, Number(opts.limit)) : 30;
437
- if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
450
+ if (runGh(['--version'], { env, run: opts.run }).status !== 0) {
438
451
  return { runtimeObserved: false, latestCiRun: null, reason: 'gh-cli-unavailable' };
439
452
  }
440
453
  const args = [
@@ -443,7 +456,7 @@ export function reportGithubCiRuntime(opts = {}) {
443
456
  '--json', 'name,conclusion,status,workflowName,displayTitle,event',
444
457
  ];
445
458
  if (opts.repo) args.push('--repo', opts.repo);
446
- const result = spawnSync('gh', args, { cwd, encoding: 'utf8', env });
459
+ const result = runGh(args, { cwd, env, run: opts.run });
447
460
  if (result.status !== 0) {
448
461
  const err = `${result.stderr || ''}${result.stdout || ''}`.slice(0, 400);
449
462
  return {
@@ -499,14 +512,14 @@ export function reportGithubCiRuntime(opts = {}) {
499
512
  export function reportGithubBranchProtection(opts = {}) {
500
513
  const cwd = opts.cwd ?? process.cwd();
501
514
  const env = opts.env ?? process.env;
502
- if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
515
+ if (runGh(['--version'], { env, run: opts.run }).status !== 0) {
503
516
  return { available: false, reason: 'gh-cli-unavailable', runtimeObserved: false, latestCiRun: null };
504
517
  }
505
518
  let repo = opts.repo;
506
519
  let branch = opts.branch;
507
520
  if (!repo || !branch) {
508
521
  const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'nameWithOwner,defaultBranchRef'];
509
- const metadata = parseJson(spawnSync('gh', args, { cwd, encoding: 'utf8', env }));
522
+ const metadata = parseJson(runGh(args, { cwd, env, run: opts.run }));
510
523
  if (!metadata?.nameWithOwner || !metadata?.defaultBranchRef?.name) {
511
524
  return { available: false, reason: 'gh-repo-unavailable', runtimeObserved: false, latestCiRun: null };
512
525
  }
@@ -514,13 +527,13 @@ export function reportGithubBranchProtection(opts = {}) {
514
527
  branch ??= metadata.defaultBranchRef.name;
515
528
  }
516
529
 
517
- const classicResult = spawnSync('gh', [
530
+ const classicResult = runGh([
518
531
  'api', `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`, '--jq',
519
532
  '{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
520
- ], { cwd, encoding: 'utf8', env });
521
- const rulesResult = spawnSync(
522
- 'gh', ['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`],
523
- { cwd, encoding: 'utf8', env }
533
+ ], { cwd, env, run: opts.run });
534
+ const rulesResult = runGh(
535
+ ['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`],
536
+ { cwd, env, run: opts.run }
524
537
  );
525
538
  const classic = parseJson(classicResult);
526
539
  const rules = parseJson(rulesResult);
@@ -10,6 +10,7 @@
10
10
  import { effectiveCapabilityDeny } from './analysis-engine.mjs';
11
11
  import { graphBlindSpotsHtml } from './graph-blind.mjs';
12
12
  import { formatRulesUnderContractHtml } from './rules-under-contract.mjs';
13
+ import { formatArkRunHtml } from './ark-run-report.mjs';
13
14
  import { primaryImprovementCompassNextAction } from './improvement-compass.mjs';
14
15
 
15
16
  // htmlEscape is injected by the caller (html-report.mjs) — importing it back
@@ -351,6 +352,7 @@ export function renderAdvisorySections(advisories, escape) {
351
352
  parseHealthHtml(advisories.parseHealth),
352
353
  graphBlindSpotsHtml(advisories.graphBlindSpots, esc),
353
354
  rulesUnderContractHtml(advisories.rulesUnderContract),
355
+ formatArkRunHtml(advisories.arkRun, esc),
354
356
  ]
355
357
  .filter(Boolean)
356
358
  .join('\n');
@@ -19,6 +19,7 @@ import {
19
19
  buildProductHonesty,
20
20
  } from './enforcement-honesty.mjs';
21
21
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
+ import { summarizeArkRunSection } from './ark-run-doctor.mjs';
22
23
  import { readBaseline, baselineOccurrenceKeys } from './violations.mjs';
23
24
  import { describePackageVersionDualTruth } from './field-install.mjs';
24
25
  import { buildDoctorImprovementCompass } from './improvement-compass-doctor.mjs';
@@ -127,13 +128,31 @@ export function buildReportDepthPayload(
127
128
  packageVersionTruth?.selfHost === true ||
128
129
  packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
129
130
  });
130
- const rulesUnderContract = summarizeRulesUnderContract(root, config, undefined, {
131
+ const classification = {
131
132
  governedPercent: coverage?.governed?.percent ?? null,
132
133
  populatedLayerCount: Array.isArray(coverage?.layers)
133
134
  ? coverage.layers.filter((row) => (row?.files ?? 0) > 0).length
134
135
  : null,
135
136
  classifiedFiles: coverage?.governed?.classifiedFiles ?? null,
137
+ };
138
+ const rulesUnderContract = summarizeRulesUnderContract(root, config, undefined, classification);
139
+ const arkRun = summarizeArkRunSection({
140
+ arkRun: config?.arkRun,
141
+ findings: activeViolations,
142
+ classification,
143
+ arkRules: {
144
+ active: rulesUnderContract?.active === true,
145
+ structureEnforced: rulesUnderContract?.mergePlanes?.structureSensors?.enforced,
146
+ structureTotal: rulesUnderContract?.mergePlanes?.structureSensors?.total,
147
+ structureAdvisory: rulesUnderContract?.mergePlanes?.structureSensors?.advisory,
148
+ invariantEnforced: rulesUnderContract?.mergePlanes?.invariants?.enforced,
149
+ invariantTotal: rulesUnderContract?.mergePlanes?.invariants?.total,
150
+ invariantAdvisory: rulesUnderContract?.mergePlanes?.invariants?.advisory,
151
+ covered: rulesUnderContract?.mergePlanes?.invariants?.covered,
152
+ uncovered: rulesUnderContract?.mergePlanes?.invariants?.uncovered,
153
+ },
136
154
  });
155
+ if (rulesUnderContract?.mergePlanes) rulesUnderContract.mergePlanes = arkRun.mergePlanes;
137
156
  // Single residual expression (parity with doctor): nextPilot || extractionCard.
138
157
  const residualPilot =
139
158
  pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
@@ -155,7 +174,7 @@ export function buildReportDepthPayload(
155
174
  residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
156
175
  pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
157
176
  arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
158
- ? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
177
+ ? { active: rulesUnderContract.active === true || arkRun?.active === true, ...rulesUnderContract.mergePlanes }
159
178
  : null,
160
179
  primaryNextAction: postGreenPath?.action ?? dualTruthNext,
161
180
  activeBlockingViolations: activeBlockingCount,
@@ -198,6 +217,7 @@ export function buildReportDepthPayload(
198
217
  // P0-B / P1-M — folded into designDepth so --report stays a single payload.
199
218
  productHonesty,
200
219
  mergePlanes: rulesUnderContract?.mergePlanes ?? null,
220
+ arkRun,
201
221
  improvementCompass,
202
222
  deepModuleCoach,
203
223
  },
@@ -28,6 +28,9 @@ import { captureGitSnapshot } from './report-snapshot-context.mjs';
28
28
 
29
29
  export { arkGitignoreAppendDecision, gitignoreCoversArkState, gitignoreHasArkNegationException } from './ark-gitignore.mjs';
30
30
 
31
+ /** Cap the rendered violation list so showcase HTML cannot dump unbounded findings. */
32
+ export const HTML_REPORT_VIOLATION_LIST_CAP = 12;
33
+
31
34
  export function detectEnforcement(root) {
32
35
  const has = (rel) => fs.existsSync(path.join(root, rel));
33
36
  const fileIncludes = (rel, needle) => {
@@ -146,6 +149,8 @@ export function buildReportSnapshot({
146
149
  mode,
147
150
  /** DF02 — thin status compass slice (mode + residual ids, notAScore). */
148
151
  improvementCompass = null,
152
+ /** RN08 — thin status ArkRun slice (notAScore; residual count, never a score). */
153
+ arkRun = null,
149
154
  }) {
150
155
  const layers = Array.isArray(config?.layers) ? config.layers : [];
151
156
  const rules = Array.isArray(config?.rules) ? config.rules : [];
@@ -193,6 +198,20 @@ export function buildReportSnapshot({
193
198
  if (improvementCompass && typeof improvementCompass === 'object') {
194
199
  snapshot.improvementCompass = improvementCompass;
195
200
  }
201
+ if (arkRun && typeof arkRun === 'object' && arkRun.notAScore === true) {
202
+ snapshot.arkRun = {
203
+ notAScore: true,
204
+ present: arkRun.active === true || arkRun.present === true,
205
+ mode: arkRun.mode === 'enforced' || arkRun.mode === 'advisory' ? arkRun.mode : null,
206
+ extraMergeTeeth: arkRun.extraMergeTeeth === true,
207
+ residual:
208
+ typeof arkRun.residual === 'number'
209
+ ? arkRun.residual
210
+ : typeof arkRun.residual?.count === 'number'
211
+ ? arkRun.residual.count
212
+ : null,
213
+ };
214
+ }
196
215
  return snapshot;
197
216
  }
198
217
 
@@ -392,9 +411,13 @@ export function renderBeginnerHtmlReport({ root, config, violations, ok, version
392
411
  })
393
412
  .join('\n');
394
413
 
414
+ const listedBeginner = violations.slice(0, HTML_REPORT_VIOLATION_LIST_CAP);
415
+ const hiddenBeginner = Math.max(0, violations.length - listedBeginner.length);
416
+ const beginnerRemainder = hiddenBeginner
417
+ ? `<div class="dim">+${hiddenBeginner} more (${violations.length} total)</div>`
418
+ : '';
395
419
  const violationRows = violations.length
396
- ? violations
397
- .slice(0, 12)
420
+ ? listedBeginner
398
421
  .map((v) => {
399
422
  const enriched = enrichViolationWithFixClass(v);
400
423
  return `<li><code>${esc(v.file)}:${v.line}</code> — ${esc(enriched.enthusiastHint ?? v.message)}</li>`;
@@ -436,6 +459,7 @@ export function renderBeginnerHtmlReport({ root, config, violations, ok, version
436
459
  </table>
437
460
  <h2>What to fix first</h2>
438
461
  <ul>${violationRows}</ul>
462
+ ${beginnerRemainder}
439
463
  <h2>Next steps</h2>
440
464
  <p><code>${arkCheckCommand(root)}</code></p>
441
465
  <p><code>${arkCommand(root, 'ark-check', '--recommend')}</code></p>
@@ -781,13 +805,22 @@ export function renderHtmlReport({
781
805
  })
782
806
  .join('\n');
783
807
 
784
- const byRule = new Map();
808
+ const listedViolations = violations.slice(0, HTML_REPORT_VIOLATION_LIST_CAP);
809
+ const hiddenViolationCount = Math.max(0, violations.length - listedViolations.length);
810
+ const ruleTotals = new Map();
785
811
  for (const v of violations) {
812
+ ruleTotals.set(v.ruleId, (ruleTotals.get(v.ruleId) || 0) + 1);
813
+ }
814
+ const byRule = new Map();
815
+ for (const v of listedViolations) {
786
816
  if (!byRule.has(v.ruleId)) byRule.set(v.ruleId, []);
787
817
  byRule.get(v.ruleId).push(v);
788
818
  }
789
- const violationBlocks = violations.length
790
- ? [...byRule.entries()]
819
+ const remainderNote = hiddenViolationCount
820
+ ? `<div class="dim">+${hiddenViolationCount} more (${violations.length} total)</div>`
821
+ : '';
822
+ const violationBlocks = listedViolations.length
823
+ ? `${[...byRule.entries()]
791
824
  .map(([ruleId, items]) => {
792
825
  const hint = FIX_HINTS[ruleId];
793
826
  const rows = items
@@ -803,12 +836,12 @@ export function renderHtmlReport({
803
836
  })
804
837
  .join('\n');
805
838
  return `<div class="vgroup">
806
- <div class="vghead"><span class="rule">${esc(ruleId)}</span> <span class="dim">${items.length}</span></div>
839
+ <div class="vghead"><span class="rule">${esc(ruleId)}</span> <span class="dim">${ruleTotals.get(ruleId) ?? items.length}</span></div>
807
840
  <ul class="vitems">${rows}</ul>
808
841
  ${hint ? `<div class="fix">fix: ${esc(hint)}</div>` : ''}
809
842
  </div>`;
810
843
  })
811
- .join('\n')
844
+ .join('\n')}${remainderNote}`
812
845
  : `<div class="clean hero-clean">
813
846
  <div class="clean-title">Architecture matches the contract</div>
814
847
  <div class="clean-body">No active violations${suppressed ? ` · ${suppressed} frozen by baseline` : ''}. This is what “honest green” looks like when coverage is real.</div>