arkgate 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.6.0 — 2026-07-17
8
+
9
+ Phase X closes: the doctor learns to see **physical shape** and agents get a governed way to
10
+ execute reorganizations, plus three field warm-ups from the 3.5.0 the field adopter validation.
11
+ Everything remains **advisory** — no verdict, exit-code, `designFitness`, or `patternBets`
12
+ change. **No breaking** CLI or `ark.config.json` changes. **No gate weaken. No apply path.**
13
+
14
+ ### Added
15
+
16
+ - **Physical cohesion sensor (X04, ADR 0010):** `doctor.physicalCohesion` reports domain
17
+ concepts exploded across mirrored directory clusters — concentration, not volume (dispersed
18
+ `use-*` hooks never fire). Deterministic path/name tokenization (framework filenames take the
19
+ topmost meaningful path segment; monorepo scaffold roots are never a concept); fixed
20
+ corpus-calibrated thresholds (`maxCluster ≥ 40` OR ≥2 anchors ≥ 20); findings ranked and
21
+ capped honestly; anchors under `app/`/`pages/` are `fixedByConvention`. `notAScore` — facts,
22
+ never a score or gate input.
23
+ - **Reshape pilot (X04):** `physicalCohesion.reshapePilot.nextPilot` is a **proposed, never
24
+ applied** card — one at a time, smallest convention-free anchor, `moveSample`/`movesTotal`,
25
+ `successSignal`, `killSwitch`, hard `doNot[]`. Real moves run only through the write gate and
26
+ atomic preflight via `/ark-loop`; merges are `/ark-architect` **merge cards** (domain
27
+ modeling, **never a codemod**); `/ark-fix` never folds reshapes into a fix batch. The
28
+ consolidation target subtree is never re-proposed as a source — the loop converges (validated
29
+ end to end: pilot → gate → kill switch → judgment → convergence).
30
+ - **Stale acknowledgments (X05):** ack entries matching no detected edge (orphaned, unknown id,
31
+ typo) land in `contractHealth.ackLifecycle` as `staleCount` + `stale[]` (sorted, capped);
32
+ doctor and report name the exact entries to fix or delete, even at zero visible smells.
33
+
34
+ ### Changed
35
+
36
+ - **Mid-name families (X06):** the family-infra carve-out matches the target's family token
37
+ against ANY source token (`HoursPersistenceAdapters -> PersistenceInfrastructure` goes
38
+ quiet); generic role words (`adapter(s)`/`gateway(s)`) never count as a family, so
39
+ `AdaptersCore` is not every `*Adapters` layer's base.
40
+ - **Report evidence overflow (X07):** per-finding evidence lists announce their 6-item cap with
41
+ an honest `(+N more)` marker; expired/stale lifecycle notes carry the same honesty.
42
+
43
+ ## 3.5.0 — 2026-07-16
44
+
45
+ Field-feedback release (Phase X, from an internal field-adoption session): the HTML report reaches
46
+ parity with the doctor and stays there by an executable rule, contract-smell acknowledgments gain
47
+ a lifecycle so migration acks cannot fossilize, and the lateral-adapter smell stops firing on a
48
+ family's own infrastructure base. Everything remains **advisory** — no verdict, `designFitness`,
49
+ or gate behavior changes. **No breaking** CLI or `ark.config.json` changes. **No gate weaken.**
50
+
51
+ ### Added
52
+
53
+ - **Report parity (X01):** `ark-check --report` now renders every doctor advisory — contract
54
+ health (smells with evidence/fix, acknowledgment honesty, invalid-sidecar warning), governance
55
+ weight, ambient state (idle/clean/findings with honest overflow), and capability-wall badges
56
+ (`pure` / `walls: …`) in the layers table. The rule is **executable**: `reportParity.test.ts`
57
+ enumerates the advisory keys `computeDoctorAdvisories` returns and fails CI when any key lacks
58
+ a `data-advisory` section — the report can never silently fall behind the product again.
59
+ - **Acknowledgment lifecycle (X02):** a contract-smell ack may carry an optional `reviewBy`
60
+ (`YYYY-MM-DD`, strict round-trip validation). Past that date the ack **stops applying** and the
61
+ smell returns with `(ack expired …)` annotated evidence; a re-ack with a fresh date wins over a
62
+ dead entry, and once any dated ack exists for an edge the dated entries govern — a leftover
63
+ undated duplicate cannot resurrect an expired exception.
64
+ Undated acks keep applying (backward compatible) but are counted and surfaced —
65
+ doctor line, report note — even when every smell is suppressed. Malformed dates never apply
66
+ (fail-loud, like a sloppy edge); non-string `reviewBy` invalidates the file. Doctor JSON gains
67
+ `contractHealth.ackLifecycle` (`{ undated, malformed, expiredCount, expired[] }`).
68
+
69
+ ### Changed
70
+
71
+ - **Lateral-adapter smell (X03):** `contract-lateral-adapter-allow` no longer fires when an
72
+ adapter layer reaches its **own family's infra base** (same leading name token and every
73
+ remaining target token an infra word — `Infra`/`Base`/`Core`/`Shared`/`Common`/`Kernel`/
74
+ `Platform`/`Foundation` — e.g. `PaymentsAdapters -> PaymentsInfra`; `PaymentsCoreAdapters` is
75
+ still a sibling). Cross-family edges, same-family non-infra siblings, and the reverse
76
+ direction (base → member) still fire.
77
+
7
78
  ## 3.4.0 — 2026-07-16
8
79
 
9
80
  Understandable execution, second slice (Phase U: U04–U07): the capability evidence shipped in
package/bin/ark-check.mjs CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  reportsDir,
60
60
  readJsonSafe,
61
61
  } from './lib/html-report.mjs';
62
+ import { computeDoctorAdvisories } from './lib/doctor-advisories.mjs';
62
63
  import { buildReportDepthPayload } from './lib/html-report-depth.mjs';
63
64
  import { shouldOpenHtmlReport, openHtmlInBrowser } from './lib/open-html.mjs';
64
65
  import {
@@ -101,7 +102,6 @@ import { runArchitectureScan } from './lib/architecture-scan.mjs';
101
102
  import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
102
103
  import { analyzePolicyTransition } from './lib/policy-delta-io.mjs';
103
104
 
104
-
105
105
  function parseArgs(argv) {
106
106
  const args = {
107
107
  root: process.cwd(),
@@ -1320,6 +1320,7 @@ async function main() {
1320
1320
  originJustCreated: !existingOrigin,
1321
1321
  adoption: adoptionForReport,
1322
1322
  designDepth,
1323
+ advisories: computeDoctorAdvisories(root, config, coverage, rules, files, ts),
1323
1324
  };
1324
1325
  const html = args.beginner
1325
1326
  ? renderBeginnerHtmlReport(reportPayload)
@@ -11,6 +11,12 @@
11
11
  * Q03 golden-pattern precedent) so the versioned config contract is untouched.
12
12
  * A malformed ack file (or a malformed edge inside it) never suppresses a smell.
13
13
  *
14
+ * X02 — acks have a lifecycle: an optional `reviewBy` (YYYY-MM-DD) marks when a
15
+ * deliberate exception must be re-reviewed. Past that date the ack stops
16
+ * applying and the smell returns annotated — migration acks cannot fossilize.
17
+ * Undated acks keep applying (backward compatible) but are counted and
18
+ * reported so they can be given a date.
19
+ *
14
20
  * Known limit (documented, deliberate): layer roles are inferred from layer NAMES
15
21
  * via substring heuristics — a name like "Auditorium" reads as audit-ish. The
16
22
  * surface is advisory, so a miss costs a warning line, never a verdict.
@@ -49,6 +55,36 @@ const MAX_MESSAGE_EDGES = 6;
49
55
  const PERIPHERAL_LAYER_RE = /observab|audit|telemetry|monitor|logging|metric|tracing/i;
50
56
  const CORE_TARGET_RE = /application|orchestr|persist|repositor/i;
51
57
  const ADAPTER_LAYER_RE = /adapter|persist|integrat|infra|gateway/i;
58
+ const FAMILY_INFRA_RE = /^(infra(structure)?|base|core|shared|common|kernel|platform|foundation)$/i;
59
+
60
+ /** Split a layer name into words: camelCase boundaries, digits, delimiters. */
61
+ function nameTokens(name) {
62
+ return String(name).match(/[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+/g) ?? [];
63
+ }
64
+
65
+ /**
66
+ * X03/X06 — an adapter reaching its OWN family's infrastructure base is not a
67
+ * lateral peer: the target reads as `<Family><InfraWords…>` and the source
68
+ * carries the family token ANYWHERE in its name (X06, field corpus names
69
+ * domain-scoped adapters `HoursPersistenceAdapters` over
70
+ * `PersistenceInfrastructure` — the family sits mid-name). EVERY remaining
71
+ * target token must be an infra word (Infra/Base/Core/Shared/…) —
72
+ * `PaymentsCoreAdapters` is still a sibling, not a base. The reverse
73
+ * direction (base → member) never matches: the target must BE the base.
74
+ * Name heuristic like the role regexes above — a miss costs a warning line.
75
+ */
76
+ function isFamilyInfrastructureEdge(from, to) {
77
+ const fromTokens = nameTokens(from);
78
+ const toTokens = nameTokens(to);
79
+ if (fromTokens.length === 0 || toTokens.length < 2) return false;
80
+ const family = toTokens[0].toLowerCase();
81
+ // A generic role word is not a family: `AdaptersCore` must not read as the
82
+ // "Adapters family" base for every *Adapters layer — that would silently
83
+ // quiet genuine cross-family edges. (`Persistence` stays a valid family.)
84
+ if (/^(adapters?|gateways?)$/.test(family)) return false;
85
+ if (family.length < 2 || !fromTokens.some((t) => t.toLowerCase() === family)) return false;
86
+ return toTokens.slice(1).every((t) => FAMILY_INFRA_RE.test(t));
87
+ }
52
88
 
53
89
  /** Collision-safe internal key for a directed edge (layer names are arbitrary strings). */
54
90
  function directedKey(from, to) {
@@ -100,9 +136,12 @@ export function loadContractSmellAcks(root) {
100
136
  typeof a === 'object' &&
101
137
  typeof a.id === 'string' &&
102
138
  typeof a.edge === 'string' &&
103
- a.edge.trim().length > 0
139
+ a.edge.trim().length > 0 &&
140
+ (a.reviewBy === undefined || typeof a.reviewBy === 'string')
104
141
  );
105
- if (!wellFormed) return invalid('every ack needs string id and non-empty string edge');
142
+ if (!wellFormed) {
143
+ return invalid('every ack needs string id, non-empty string edge, and string reviewBy when present');
144
+ }
106
145
  return { path: relPath, exists: true, acks };
107
146
  }
108
147
 
@@ -121,10 +160,44 @@ function normalizeAckEdge(id, edge) {
121
160
  return raw;
122
161
  }
123
162
 
124
- function isAcknowledged(ackState, id, canonicalEdge) {
125
- if (!ackState || ackState.invalid || !Array.isArray(ackState.acks)) return false;
126
- if (canonicalEdge == null) return false;
127
- return ackState.acks.some((a) => a.id === id && normalizeAckEdge(id, a.edge) === canonicalEdge);
163
+ /**
164
+ * X02 lifecycle status of one ack entry. `undated` and `current` apply
165
+ * (suppress); `expired` and `malformed` do not — fail-loud like a sloppy edge.
166
+ * Strict round-trip date check: `2026-02-30` must not pass as valid.
167
+ */
168
+ function ackLifecycleStatus(ack, today) {
169
+ const rb = ack.reviewBy;
170
+ if (rb === undefined || rb === null) return 'undated';
171
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(rb)) return 'malformed';
172
+ const parsed = new Date(`${rb}T00:00:00.000Z`);
173
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== rb) {
174
+ return 'malformed';
175
+ }
176
+ return typeof today === 'string' && rb < today ? 'expired' : 'current';
177
+ }
178
+
179
+ /**
180
+ * Match a detected edge against the ack sidecar with lifecycle applied.
181
+ * Returns how the edge resolves: `none` (no matching ack), an applying status
182
+ * (`current` | `undated`), or a non-applying one (`expired` | `malformed`,
183
+ * with the reviewBy that failed). Once ANY dated ack exists for the edge, the
184
+ * dated entries govern — a leftover undated duplicate cannot resurrect an
185
+ * expired exception. Among dated entries a fresh re-ack wins over a dead one.
186
+ */
187
+ function resolveAck(ackState, id, canonicalEdge, today) {
188
+ if (!ackState || ackState.invalid || !Array.isArray(ackState.acks)) return { status: 'none' };
189
+ if (canonicalEdge == null) return { status: 'none' };
190
+ let dead = null;
191
+ let undated = false;
192
+ for (const a of ackState.acks) {
193
+ if (a.id !== id || normalizeAckEdge(id, a.edge) !== canonicalEdge) continue;
194
+ const status = ackLifecycleStatus(a, today);
195
+ if (status === 'current') return { status };
196
+ if (status === 'undated') undated = true;
197
+ else dead ??= { status, reviewBy: a.reviewBy };
198
+ }
199
+ if (dead) return dead;
200
+ return undated ? { status: 'undated' } : { status: 'none' };
128
201
  }
129
202
 
130
203
  /**
@@ -140,7 +213,8 @@ export function analyzeContractSmells(
140
213
  config,
141
214
  coverage = null,
142
215
  ackState = { exists: false, acks: [] },
143
- effectiveRules = null
216
+ effectiveRules = null,
217
+ today = null
144
218
  ) {
145
219
  const layers = Array.isArray(config?.layers) ? config.layers : [];
146
220
  const rules = wellFormedRules(config, effectiveRules);
@@ -188,10 +262,12 @@ export function analyzeContractSmells(
188
262
  }
189
263
  }
190
264
 
191
- // 3) Lateral adapter-to-adapter explicit allows. Skip only edges the peripheral
192
- // sensor already flagged (peripheral source AND core-ish target).
265
+ // 3) Lateral adapter-to-adapter explicit allows. Skip edges the peripheral
266
+ // sensor already flagged (peripheral source AND core-ish target), and
267
+ // X03: an adapter reaching its own family's infra base is not a peer.
193
268
  for (const r of explicitAllows) {
194
269
  if (PERIPHERAL_LAYER_RE.test(r.from) && CORE_TARGET_RE.test(r.to)) continue;
270
+ if (isFamilyInfrastructureEdge(r.from, r.to)) continue;
195
271
  if (ADAPTER_LAYER_RE.test(r.from) && ADAPTER_LAYER_RE.test(r.to)) {
196
272
  add(
197
273
  'contract-lateral-adapter-allow',
@@ -221,8 +297,28 @@ export function analyzeContractSmells(
221
297
  }
222
298
  }
223
299
 
300
+ // X05 — an ack that matches no detected edge is stale: orphaned by a fixed
301
+ // contract, a quieted heuristic, or a typo. Detected BEFORE ack filtering.
302
+ const detectedEdges = new Map();
303
+ for (const [id, entries] of Object.entries(findings)) {
304
+ detectedEdges.set(id, new Set(entries.map((e) => e.edge).filter((e) => e != null)));
305
+ }
306
+ const staleEdges = [];
307
+ if (ackState && !ackState.invalid && Array.isArray(ackState.acks)) {
308
+ for (const a of ackState.acks) {
309
+ const canonical = normalizeAckEdge(a.id, a.edge);
310
+ if (canonical != null && detectedEdges.get(a.id)?.has(canonical)) continue;
311
+ staleEdges.push({ id: a.id, edge: a.edge });
312
+ }
313
+ // Stable under sidecar reordering, like every other output here.
314
+ staleEdges.sort((a, b) =>
315
+ a.id === b.id ? (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0) : a.id < b.id ? -1 : 1
316
+ );
317
+ }
318
+
224
319
  const smells = [];
225
320
  let matchedAcks = 0;
321
+ const ackLifecycle = { undated: 0, malformed: 0, expired: [], stale: staleEdges };
226
322
  for (const id of CONTRACT_SMELL_IDS) {
227
323
  const entries = findings[id];
228
324
  if (!entries || entries.length === 0) continue;
@@ -232,8 +328,20 @@ export function analyzeContractSmells(
232
328
  for (const entry of entries) {
233
329
  if (seenDetail.has(entry.detail)) continue;
234
330
  seenDetail.add(entry.detail);
235
- if (isAcknowledged(ackState, id, entry.edge)) {
331
+ const ack = resolveAck(ackState, id, entry.edge, today);
332
+ if (ack.status === 'current' || ack.status === 'undated') {
236
333
  acknowledgedEdges += 1;
334
+ if (ack.status === 'undated') ackLifecycle.undated += 1;
335
+ continue;
336
+ }
337
+ if (ack.status === 'expired') {
338
+ ackLifecycle.expired.push({ id, edge: entry.edge, reviewBy: ack.reviewBy });
339
+ kept.push({ ...entry, detail: `${entry.detail} (ack expired ${ack.reviewBy})` });
340
+ continue;
341
+ }
342
+ if (ack.status === 'malformed') {
343
+ ackLifecycle.malformed += 1;
344
+ kept.push({ ...entry, detail: `${entry.detail} (ack review-by malformed)` });
237
345
  continue;
238
346
  }
239
347
  kept.push(entry);
@@ -255,20 +363,23 @@ export function analyzeContractSmells(
255
363
  acknowledgedEdges,
256
364
  });
257
365
  }
258
- return { smells, matchedAcks };
366
+ return { smells, matchedAcks, ackLifecycle };
259
367
  }
260
368
 
261
369
  /**
262
370
  * Detect contract smells (compat wrapper over analyzeContractSmells).
371
+ * Defaults `today` to the real clock so expired acks stop applying on every
372
+ * public path, not only through the doctor; pass `null` to disable expiry.
263
373
  * @returns {Array<{id: string, severity: 'warn', message: string, outcome: string, evidence: string[], fix: string, acknowledgedEdges: number}>}
264
374
  */
265
375
  export function detectContractSmells(
266
376
  config,
267
377
  coverage = null,
268
378
  ackState = { exists: false, acks: [] },
269
- effectiveRules = null
379
+ effectiveRules = null,
380
+ today = todayUtc()
270
381
  ) {
271
- return analyzeContractSmells(config, coverage, ackState, effectiveRules).smells;
382
+ return analyzeContractSmells(config, coverage, ackState, effectiveRules, today).smells;
272
383
  }
273
384
 
274
385
  function messageFor(id, entries) {
@@ -399,6 +510,11 @@ export function computeGovernanceWeight(config, coverage = null, effectiveRules
399
510
  };
400
511
  }
401
512
 
513
+ /** Today as UTC YYYY-MM-DD — the only clock read; tests inject `today` instead. */
514
+ function todayUtc() {
515
+ return new Date().toISOString().slice(0, 10);
516
+ }
517
+
402
518
  /**
403
519
  * One-call compute for doctor: acks + smells + governance weight + JSON-ready summary.
404
520
  * `rules` should be the rules actually in force (manifest-aware callers pass them).
@@ -407,12 +523,19 @@ export function computeGovernanceWeight(config, coverage = null, effectiveRules
407
523
  * @param {object} config
408
524
  * @param {object|null} coverage
409
525
  * @param {object[]|null} [rules]
526
+ * @param {string} [today] UTC YYYY-MM-DD for ack lifecycle; defaults to the real clock
410
527
  */
411
- export function computeContractHealth(root, config, coverage, rules = null) {
528
+ export function computeContractHealth(root, config, coverage, rules = null, today = todayUtc()) {
412
529
  const ackState = loadContractSmellAcks(root);
413
- const { smells, matchedAcks } = analyzeContractSmells(config, coverage, ackState, rules);
530
+ const { smells, matchedAcks, ackLifecycle } = analyzeContractSmells(
531
+ config,
532
+ coverage,
533
+ ackState,
534
+ rules,
535
+ today
536
+ );
414
537
  return {
415
- ...summarizeContractHealth(smells, ackState, matchedAcks),
538
+ ...summarizeContractHealth(smells, ackState, matchedAcks, ackLifecycle),
416
539
  governanceWeight: computeGovernanceWeight(config, coverage, rules),
417
540
  smells,
418
541
  };
@@ -447,7 +570,13 @@ export function formatContractHealthLines(smells, health) {
447
570
  const list = smells ?? [];
448
571
  const gw = health?.governanceWeight;
449
572
  const weightNoteworthy = gw?.weight === 'heavy' || gw?.weight === 'light';
450
- if (list.length === 0 && !health?.ackFile?.invalid && !weightNoteworthy) return rows;
573
+ const lc = health?.ackLifecycle;
574
+ // Undated and stale acks must surface even when every smell is suppressed —
575
+ // fossilization (X02) and orphaned entries (X05) hide exactly there.
576
+ const lifecycleNoteworthy = (lc?.undated ?? 0) > 0 || (lc?.staleCount ?? 0) > 0;
577
+ if (list.length === 0 && !health?.ackFile?.invalid && !weightNoteworthy && !lifecycleNoteworthy) {
578
+ return rows;
579
+ }
451
580
  if (health?.ackFile?.invalid) {
452
581
  rows.push({
453
582
  mark: 'warn',
@@ -470,6 +599,32 @@ export function formatContractHealthLines(smells, health) {
470
599
  if ((health?.acknowledged ?? 0) > 0) {
471
600
  rows.push({ mark: 'dim', text: `acknowledged edges applied: ${health.acknowledged}` });
472
601
  }
602
+ if ((lc?.expiredCount ?? 0) > 0) {
603
+ rows.push({
604
+ mark: 'warn',
605
+ text: `${lc.expiredCount} acknowledgment(s) past review-by — the smell is active again; re-review the edge and re-ack with a new date, or fix the contract.`,
606
+ });
607
+ }
608
+ if ((lc?.malformed ?? 0) > 0) {
609
+ rows.push({
610
+ mark: 'warn',
611
+ text: `${lc.malformed} acknowledgment(s) have a malformed review-by (expected YYYY-MM-DD) — they are ignored, not silently applied.`,
612
+ });
613
+ }
614
+ if ((lc?.undated ?? 0) > 0) {
615
+ rows.push({
616
+ mark: 'dim',
617
+ text: `${lc.undated} applied acknowledgment(s) have no review-by date — add one so migration acks cannot fossilize.`,
618
+ });
619
+ }
620
+ if ((lc?.staleCount ?? 0) > 0) {
621
+ const shown = (lc.stale ?? []).slice(0, 4).map((s) => s.edge);
622
+ const more = lc.staleCount > shown.length ? ` …(+${lc.staleCount - shown.length} more)` : '';
623
+ rows.push({
624
+ mark: 'dim',
625
+ text: `${lc.staleCount} acknowledgment(s) match no detected edge — stale; fix the edge string or delete the entry: ${shown.join(', ')}${more}`,
626
+ });
627
+ }
473
628
  if (weightNoteworthy) {
474
629
  rows.push({
475
630
  mark: 'warn',
@@ -487,18 +642,38 @@ export function formatContractHealthLines(smells, health) {
487
642
  /**
488
643
  * Contract-health summary for doctor JSON / human output. Advisory only.
489
644
  * `acknowledged` counts ack entries that MATCHED a detected edge (stale acks count 0).
645
+ * X02 — `ackLifecycle` reports how applied acks age: `undated` applied without
646
+ * a review-by, `expired` past it (no longer applied), `malformed` bad dates.
647
+ * X05 — `stale` counts ack entries matching NO detected edge (orphaned or
648
+ * typo'd); they suppress nothing and should be fixed or deleted.
490
649
  *
491
650
  * @param {ReturnType<typeof detectContractSmells>} smells
492
651
  * @param {ReturnType<typeof loadContractSmellAcks>} ackState
493
652
  * @param {number} [matchedAcks]
653
+ * @param {{ undated: number, malformed: number, expired: Array<{id: string, edge: string, reviewBy: string}>, stale: Array<{id: string, edge: string}> }} [ackLifecycle]
494
654
  */
495
- export function summarizeContractHealth(smells, ackState = { exists: false, acks: [] }, matchedAcks = 0) {
655
+ export function summarizeContractHealth(
656
+ smells,
657
+ ackState = { exists: false, acks: [] },
658
+ matchedAcks = 0,
659
+ ackLifecycle = { undated: 0, malformed: 0, expired: [], stale: [] }
660
+ ) {
496
661
  const list = Array.isArray(smells) ? smells : [];
662
+ const expired = Array.isArray(ackLifecycle?.expired) ? ackLifecycle.expired : [];
663
+ const stale = Array.isArray(ackLifecycle?.stale) ? ackLifecycle.stale : [];
497
664
  return {
498
665
  status: list.length > 0 ? 'contract-smells' : 'ok',
499
666
  smellCount: list.length,
500
667
  ids: list.map((s) => s.id),
501
668
  acknowledged: ackState?.invalid ? 0 : matchedAcks,
669
+ ackLifecycle: {
670
+ undated: ackLifecycle?.undated ?? 0,
671
+ malformed: ackLifecycle?.malformed ?? 0,
672
+ expiredCount: expired.length,
673
+ expired: expired.slice(0, MAX_EVIDENCE),
674
+ staleCount: stale.length,
675
+ stale: stale.slice(0, MAX_EVIDENCE),
676
+ },
502
677
  advisory: true,
503
678
  label:
504
679
  list.length > 0
@@ -1,20 +1,33 @@
1
1
  /**
2
2
  * Doctor's advisory sensors, aggregated (W01 contract health + U05 ambient
3
- * state). Advisory only: nothing here feeds a verdict, designFitness, or an
4
- * exit code. One seam keeps doctor-plan.mjs inside its module budget as new
5
- * advisory surfaces land.
3
+ * state + X04 physical cohesion). Advisory only: nothing here feeds a
4
+ * verdict, designFitness, or an exit code. One seam keeps doctor-plan.mjs
5
+ * inside its module budget as new advisory surfaces land.
6
6
  */
7
7
  import { computeAmbientState, printAmbientStateSection } from './ambient-state.mjs';
8
8
  import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
9
+ import {
10
+ computePhysicalCohesion,
11
+ computeReshapePilot,
12
+ printPhysicalCohesionSection,
13
+ } from './physical-cohesion.mjs';
9
14
 
10
15
  export function computeDoctorAdvisories(root, config, cov, rules, files, ts) {
16
+ const physicalCohesion = computePhysicalCohesion(root, files);
17
+ physicalCohesion.reshapePilot = computeReshapePilot(physicalCohesion, files, root);
11
18
  return {
12
19
  contractHealth: computeContractHealth(root, config, cov, rules),
13
20
  ambientState: computeAmbientState(ts, root, config, files),
21
+ physicalCohesion,
14
22
  };
15
23
  }
16
24
 
17
25
  export function printDoctorAdvisories(advisories, io) {
18
26
  printContractHealthSection(advisories.contractHealth, io);
19
27
  printAmbientStateSection(advisories.ambientState, io);
28
+ printPhysicalCohesionSection(
29
+ advisories.physicalCohesion,
30
+ advisories.physicalCohesion?.reshapePilot,
31
+ io
32
+ );
20
33
  }
@@ -55,7 +55,6 @@ function normalize(value) {
55
55
  }
56
56
 
57
57
 
58
-
59
58
  export function computeCoverage(root, config, files, rules) {
60
59
  const layers = config.layers ?? [];
61
60
  const counts = new Map(layers.map((layer) => [layer.name, 0]));
@@ -425,7 +424,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
425
424
  patternBets: patternBetsForLoop,
426
425
  designSmells,
427
426
  });
428
- const { contractHealth, ambientState } = computeDoctorAdvisories(root, config, cov, rules, files, options.ts); // W01+U05 advisories — never a verdict
427
+ const { contractHealth, ambientState, physicalCohesion } = computeDoctorAdvisories(root, config, cov, rules, files, options.ts); // W01+U05+X04 advisories — never a verdict
429
428
 
430
429
  if (asJson) {
431
430
  console.log(
@@ -462,10 +461,11 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
462
461
  goldenPattern,
463
462
  // Q04: one-pilot loop (extraction card → re-doctor).
464
463
  pilotLoop,
465
- // W01: contract-health meta-lint (advisory; verdict unchanged).
464
+ // Advisories, never a verdict: W01 contract health, U05 ambient
465
+ // state (opt-in), X04 physical cohesion + proposed reshape pilot.
466
466
  contractHealth,
467
- // U05: ambient-state sensor (advisory; opt-in; verdict unchanged).
468
467
  ambientState,
468
+ physicalCohesion,
469
469
  governed: cov.governed,
470
470
  emptyLayers: cov.emptyLayers,
471
471
  layersWithoutRules: cov.layersWithoutRules,
@@ -647,7 +647,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
647
647
  );
648
648
  }
649
649
 
650
- printDoctorAdvisories({ contractHealth, ambientState }, { line, warn, color }); // advisory sections
650
+ printDoctorAdvisories({ contractHealth, ambientState, physicalCohesion }, { line, warn, color }); // advisory sections
651
651
 
652
652
  console.log('');
653
653
  console.log(color.bold('Coverage'));