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 +71 -0
- package/bin/ark-check.mjs +2 -1
- package/bin/lib/contract-smells.mjs +193 -18
- package/bin/lib/doctor-advisories.mjs +16 -3
- package/bin/lib/doctor-plan.mjs +5 -5
- package/bin/lib/html-report-advisories.mjs +195 -0
- package/bin/lib/html-report.mjs +6 -0
- package/bin/lib/physical-cohesion.mjs +230 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/agent-guide.md +19 -3
- package/docs/package-surface.md +3 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/templates/skills/ark-architect.md +9 -0
- package/templates/skills/ark-fix.md +7 -0
- package/templates/skills/ark-loop.md +16 -0
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)
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
4
|
-
* exit code. One seam keeps doctor-plan.mjs
|
|
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
|
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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'));
|