arkgate 4.8.19 → 4.8.20
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 +101 -0
- package/README.md +4 -4
- package/bin/ark-check-runtime.mjs +23 -0
- package/bin/ark-layer-match.mjs +141 -9
- package/bin/ark.mjs +6 -2
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/config-contract.mjs +1 -0
- package/bin/lib/doctor-advisories.mjs +2 -1
- package/bin/lib/doctor-plan.mjs +2 -0
- package/bin/lib/enforcement-honesty.mjs +30 -2
- package/bin/lib/html-report-depth.mjs +5 -1
- package/bin/lib/invariant-coverage.mjs +44 -13
- package/bin/lib/package-manager.mjs +5 -2
- package/bin/lib/physical-cohesion.mjs +68 -51
- package/bin/lib/reshape-decisions.mjs +2 -2
- package/bin/lib/rules-under-contract.mjs +40 -4
- package/bin/lib/start-install-recovery.mjs +156 -0
- package/bin/lib/start-preview.mjs +0 -54
- package/dist/{configTypes-VD0qcubY.d.ts → configTypes-VVknqcTJ.d.ts} +2 -0
- package/dist/{diagnosticCatalog-D2v0ASN2.d.ts → diagnosticCatalog-BPYWNyJR.d.ts} +9 -3
- package/dist/eslint/index.cjs +4 -4
- package/dist/eslint/index.d.ts +12 -4
- package/dist/eslint/index.js +4 -4
- package/dist/index.cjs +18 -18
- package/dist/index.d.ts +8 -4
- package/dist/index.js +20 -20
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +10 -10
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +10 -10
- package/dist/{types-BSzRy2X1.d.ts → types-Cs-46DRu.d.ts} +1 -1
- package/dist/{types-D5GT5ZT8.d.ts → types-p_fCp6P-.d.ts} +1 -1
- package/docs/README.md +1 -1
- package/docs/configuration.md +20 -5
- package/docs/develop.md +1 -1
- package/docs/package-surface.md +2 -2
- package/docs/use.md +1 -1
- package/package.json +3 -3
- package/schemas/ark.config.schema.json +6 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +15 -1
- package/templates/agent-skills/ark-autopilot/SKILL.md +2 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
- package/templates/agent-skills/ark-explain/SKILL.md +1 -1
- package/templates/agent-skills/ark-explore/SKILL.md +13 -1
- package/templates/agent-skills/ark-upgrade/SKILL.md +5 -3
- package/templates/skills/ark-adopt.md +15 -1
- package/templates/skills/ark-autopilot.md +2 -0
- package/templates/skills/ark-coverage.md +1 -1
- package/templates/skills/ark-explain.md +1 -1
- package/templates/skills/ark-explore.md +13 -1
- package/templates/skills/ark-upgrade.md +5 -3
|
@@ -200,7 +200,8 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
200
200
|
physicalCohesion,
|
|
201
201
|
files,
|
|
202
202
|
root,
|
|
203
|
-
decisionMemory
|
|
203
|
+
decisionMemory,
|
|
204
|
+
{ layers: config?.layers, rules: rules ?? config?.rules }
|
|
204
205
|
);
|
|
205
206
|
// Prefer architecture facts paths when available; still union the doctor walk
|
|
206
207
|
// so an empty-catalog residual can see Domain files the facts subset missed.
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
import {
|
|
60
60
|
buildCoverageHonesty,
|
|
61
61
|
computeDoctorEnforcementHonesty,
|
|
62
|
+
physicalCohesionResidualRemains,
|
|
62
63
|
} from './enforcement-honesty.mjs';
|
|
63
64
|
import {
|
|
64
65
|
computePureLayerOptInNudge,
|
|
@@ -693,6 +694,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
693
694
|
github: githubForBoundary,
|
|
694
695
|
adoptionStance: stanceFile,
|
|
695
696
|
stewardNudge: doctorAdvisories.stewardNudge,
|
|
697
|
+
physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion),
|
|
696
698
|
});
|
|
697
699
|
|
|
698
700
|
// Improvement compass: projection only — never feeds ok/valid/goal.met.
|
|
@@ -288,14 +288,26 @@ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extra
|
|
|
288
288
|
* primaryNextAction?: string | null,
|
|
289
289
|
* operatingMode?: string | null,
|
|
290
290
|
* activeBlockingViolations?: number | null,
|
|
291
|
+
* physicalCohesionResidual?: boolean,
|
|
291
292
|
* }} input
|
|
292
293
|
*/
|
|
294
|
+
export function physicalCohesionResidualRemains(cohesion) {
|
|
295
|
+
const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
|
|
296
|
+
if (findings.length === 0) return false;
|
|
297
|
+
const covered = new Set();
|
|
298
|
+
for (const row of cohesion?.reshapeDecisions?.current ?? []) {
|
|
299
|
+
if (row?.suppressesPilot === true && typeof row.concept === 'string') covered.add(row.concept);
|
|
300
|
+
}
|
|
301
|
+
return findings.some((finding) => !covered.has(finding?.concept));
|
|
302
|
+
}
|
|
303
|
+
|
|
293
304
|
export function buildProductHonesty(input = {}) {
|
|
294
305
|
const reasons = [];
|
|
295
306
|
const cov = input.coverageHonesty;
|
|
296
307
|
const base = input.baselineHonesty;
|
|
297
308
|
const write = input.writePathHonesty;
|
|
298
309
|
const designWeak = input.designWeak === true;
|
|
310
|
+
const cohesionResidual = input.physicalCohesionResidual === true;
|
|
299
311
|
const dualTruth = input.packageVersionTruth?.dualTruth === true;
|
|
300
312
|
const pinCode = input.packageVersionTruth?.code || write?.packagePinCode || null;
|
|
301
313
|
const pinAbsent =
|
|
@@ -365,6 +377,14 @@ export function buildProductHonesty(input = {}) {
|
|
|
365
377
|
});
|
|
366
378
|
}
|
|
367
379
|
|
|
380
|
+
if (cohesionResidual) {
|
|
381
|
+
reasons.push({
|
|
382
|
+
id: 'physical-cohesion-residual',
|
|
383
|
+
message:
|
|
384
|
+
'Physical cohesion residual remains — the gate verdict and exit code are unchanged, and the reshape card is one judgment, not a queue to empty.',
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
368
388
|
if (dualTruth) {
|
|
369
389
|
reasons.push({
|
|
370
390
|
id: 'package-version-dual-truth',
|
|
@@ -551,6 +571,12 @@ export function buildProductHonesty(input = {}) {
|
|
|
551
571
|
headline = 'Advisory-only adoption — merge status is not required';
|
|
552
572
|
} else if (!unfinished) {
|
|
553
573
|
headline = 'Honesty clear on residual signals';
|
|
574
|
+
} else if (
|
|
575
|
+
wholeTreeGoverned &&
|
|
576
|
+
architectureReasons.length === 1 &&
|
|
577
|
+
architectureReasons[0].id === 'physical-cohesion-residual'
|
|
578
|
+
) {
|
|
579
|
+
headline = 'Residual remaining';
|
|
554
580
|
} else if (coverageIncomplete) {
|
|
555
581
|
headline = 'Not finished / not whole-tree guarantee';
|
|
556
582
|
} else {
|
|
@@ -592,8 +618,8 @@ export function buildProductHonesty(input = {}) {
|
|
|
592
618
|
: 'unknown';
|
|
593
619
|
|
|
594
620
|
return {
|
|
595
|
-
finished: !unfinished && wholeTreeGoverned && !designWeak && activeBlocking === 0,
|
|
596
|
-
elegant: !designWeak && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
|
|
621
|
+
finished: !unfinished && wholeTreeGoverned && !designWeak && !cohesionResidual && activeBlocking === 0,
|
|
622
|
+
elegant: !designWeak && !cohesionResidual && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
|
|
597
623
|
wholeTreeGuarantee:
|
|
598
624
|
wholeTreeGoverned &&
|
|
599
625
|
!designWeak &&
|
|
@@ -656,6 +682,7 @@ export function computeDoctorEnforcementHonesty({
|
|
|
656
682
|
stewardNudge,
|
|
657
683
|
nativeFailClosed,
|
|
658
684
|
nativeFailClosedPolicy,
|
|
685
|
+
physicalCohesionResidual,
|
|
659
686
|
} = {}) {
|
|
660
687
|
const coverageHonesty = buildCoverageHonesty({
|
|
661
688
|
percent: governedPercent,
|
|
@@ -713,6 +740,7 @@ export function computeDoctorEnforcementHonesty({
|
|
|
713
740
|
considerMergeBoundary: true,
|
|
714
741
|
emptyStewards,
|
|
715
742
|
stewardNudge,
|
|
743
|
+
physicalCohesionResidual,
|
|
716
744
|
});
|
|
717
745
|
return {
|
|
718
746
|
coverageHonesty,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
buildBaselineHonesty,
|
|
18
18
|
buildWritePathHonesty,
|
|
19
19
|
buildProductHonesty,
|
|
20
|
+
physicalCohesionResidualRemains,
|
|
20
21
|
} from './enforcement-honesty.mjs';
|
|
21
22
|
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
22
23
|
import { attachExtraDoctorSections } from './doctor-advisories.mjs';
|
|
@@ -25,6 +26,7 @@ import { describePackageVersionDualTruth } from './field-install.mjs';
|
|
|
25
26
|
import { buildDoctorImprovementCompass } from './improvement-compass-doctor.mjs';
|
|
26
27
|
import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
|
|
27
28
|
import { computePhysicalCohesion } from './physical-cohesion.mjs';
|
|
29
|
+
import { computeReshapeDecisionMemory } from './reshape-decisions.mjs';
|
|
28
30
|
import { POST_GREEN_LEDE, operatingModeTitle } from './product-copy.mjs';
|
|
29
31
|
|
|
30
32
|
function esc(value) {
|
|
@@ -153,6 +155,8 @@ export function buildReportDepthPayload(
|
|
|
153
155
|
: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
|
|
154
156
|
? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
|
|
155
157
|
: null;
|
|
158
|
+
const physicalCohesion = computePhysicalCohesion(root, files);
|
|
159
|
+
physicalCohesion.reshapeDecisions = computeReshapeDecisionMemory(root, files).summary;
|
|
156
160
|
const productHonesty = buildProductHonesty({
|
|
157
161
|
coverageHonesty,
|
|
158
162
|
baselineHonesty,
|
|
@@ -169,9 +173,9 @@ export function buildReportDepthPayload(
|
|
|
169
173
|
: null,
|
|
170
174
|
primaryNextAction: postGreenPath?.action ?? dualTruthNext,
|
|
171
175
|
activeBlockingViolations: activeBlockingCount,
|
|
176
|
+
physicalCohesionResidual: physicalCohesionResidualRemains(physicalCohesion),
|
|
172
177
|
});
|
|
173
178
|
// Doctor parity: same physical-cohesion + baseline stale facts as runDoctor.
|
|
174
|
-
const physicalCohesion = computePhysicalCohesion(root, files);
|
|
175
179
|
const baselineStale =
|
|
176
180
|
typeof baselineSplit.baselineStale === 'number' ? baselineSplit.baselineStale : null;
|
|
177
181
|
// Improvement compass — same projection as doctor; notAScore; never a gate input.
|
|
@@ -19,7 +19,7 @@ export const INVARIANT_COVERAGE_ROOTS_MESSAGE = 'A domain invariant is enforced,
|
|
|
19
19
|
* `omitBudget` drops the budget clause and the load totals for messages whose
|
|
20
20
|
* own text already carries them — the same number twice reads as two facts.
|
|
21
21
|
*/
|
|
22
|
-
function formatCoverageDiscards(stats, omitBudget = false) {
|
|
22
|
+
export function formatCoverageDiscards(stats, omitBudget = false) {
|
|
23
23
|
if (!stats)
|
|
24
24
|
return '';
|
|
25
25
|
const d = stats.discarded;
|
|
@@ -62,22 +62,50 @@ function titleMatchesInvariant(content, id) {
|
|
|
62
62
|
const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*['"\`][^'"\`]*${escaped}[^'"\`]*['"\`]`, 'i');
|
|
63
63
|
return re.test(content) || content.includes(id);
|
|
64
64
|
}
|
|
65
|
-
function
|
|
65
|
+
function escapeRegExp(value) {
|
|
66
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* True when `name` is declared here. Imports and calls do not count.
|
|
70
|
+
* `export const name =` is the same binding shape sensors already treat as a
|
|
71
|
+
* declaration (arrow or function). The last segment of `Aggregate.method` is
|
|
72
|
+
* the name that must be declared.
|
|
73
|
+
*/
|
|
74
|
+
function declaresIdentifier(content, name) {
|
|
75
|
+
const n = escapeRegExp(name);
|
|
76
|
+
const functionDecl = new RegExp(`(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:declare\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${n}\\s*[(<]`);
|
|
77
|
+
const classDecl = new RegExp(`(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:abstract\\s+)?class\\s+${n}\\b`);
|
|
78
|
+
const constDecl = new RegExp(`(?:^|[\\s;{}])export\\s+(?:const|let|var)\\s+${n}\\s*=`);
|
|
79
|
+
const methodDecl = new RegExp(`(?:^|[\\n;{}])\\s*(?:(?:public|private|protected|static|async|readonly|override|abstract|get|set|declare)\\s+)*${n}\\s*(?:<[^>\\n]*>)?\\s*\\([^;{}]*\\)\\s*(?::\\s*[^;{]+)?\\s*\\{`);
|
|
80
|
+
return (functionDecl.test(content) ||
|
|
81
|
+
classDecl.test(content) ||
|
|
82
|
+
constDecl.test(content) ||
|
|
83
|
+
methodDecl.test(content));
|
|
84
|
+
}
|
|
85
|
+
/** Witness path, or undefined when no non-test file declares `symbol`. */
|
|
86
|
+
function symbolPresent(fileContents, testFiles, symbol) {
|
|
66
87
|
if (!symbol)
|
|
67
|
-
return
|
|
68
|
-
// Support Aggregate.method or bare method name.
|
|
88
|
+
return undefined;
|
|
69
89
|
const parts = symbol.split('.');
|
|
70
|
-
const needle = parts[parts.length - 1];
|
|
90
|
+
const needle = parts[parts.length - 1] ?? '';
|
|
91
|
+
if (!needle)
|
|
92
|
+
return undefined;
|
|
71
93
|
const className = parts.length > 1 ? parts[0] : null;
|
|
72
|
-
|
|
94
|
+
const tests = new Set(testFiles.map((file) => file.replace(/\\/g, '/').replace(/^\.\//, '')));
|
|
95
|
+
const files = Object.keys(fileContents).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
98
|
+
if (tests.has(normalized))
|
|
99
|
+
continue;
|
|
100
|
+
const content = fileContents[file];
|
|
101
|
+
if (!content)
|
|
102
|
+
continue;
|
|
73
103
|
if (className && !content.includes(className))
|
|
74
104
|
continue;
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
105
|
+
if (declaresIdentifier(content, needle))
|
|
106
|
+
return file;
|
|
79
107
|
}
|
|
80
|
-
return
|
|
108
|
+
return undefined;
|
|
81
109
|
}
|
|
82
110
|
export function evaluateInvariantCoverage(input) {
|
|
83
111
|
const invariants = input.arkRules.invariants ?? [];
|
|
@@ -131,9 +159,11 @@ export function evaluateInvariantCoverage(input) {
|
|
|
131
159
|
if (testEvidenceFile !== undefined)
|
|
132
160
|
evidence.push('test-title');
|
|
133
161
|
}
|
|
134
|
-
|
|
162
|
+
const symbolEvidenceFile = symbol
|
|
163
|
+
? symbolPresent(input.fileContents, testFiles, symbol)
|
|
164
|
+
: undefined;
|
|
165
|
+
if (symbolEvidenceFile)
|
|
135
166
|
evidence.push('symbol');
|
|
136
|
-
}
|
|
137
167
|
// Covered if any requested evidence is present.
|
|
138
168
|
// When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
|
|
139
169
|
const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
|
|
@@ -154,6 +184,7 @@ export function evaluateInvariantCoverage(input) {
|
|
|
154
184
|
partial,
|
|
155
185
|
description: inv.description,
|
|
156
186
|
...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
|
|
187
|
+
...(symbolEvidenceFile !== undefined ? { symbolEvidenceFile } : {}),
|
|
157
188
|
...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
|
|
158
189
|
coverageRootsDeclared: rootsDeclared,
|
|
159
190
|
});
|
|
@@ -94,9 +94,12 @@ export function arkCommand(root, bin, argsStr = '') {
|
|
|
94
94
|
/**
|
|
95
95
|
* Run an Ark binary from package `arkgate` when local bins may be missing.
|
|
96
96
|
* Bare `npx arkgate-check` 404s — npx treats that bin name as its own package.
|
|
97
|
+
* Pass `packageSpec` (e.g. `arkgate@4.8.19`) when Age blocked a local add.
|
|
97
98
|
*/
|
|
98
|
-
export function arkPackageRecoveryCommand(bin, argsStr = '') {
|
|
99
|
-
|
|
99
|
+
export function arkPackageRecoveryCommand(bin, argsStr = '', packageSpec) {
|
|
100
|
+
const spec =
|
|
101
|
+
typeof packageSpec === 'string' && packageSpec.trim() ? packageSpec.trim() : 'arkgate';
|
|
102
|
+
return `npx --package=${spec} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/**
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* as W01 layer roles: a miss costs a warning line, never a verdict.
|
|
16
16
|
*/
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { layerForRelativePath, sliceIdForPath } from '../ark-layer-match.mjs';
|
|
18
19
|
|
|
19
20
|
/** ADR 0010 D3 — corpus-calibrated, fixed. */
|
|
20
21
|
const CLUSTER_MIN = 40;
|
|
@@ -126,76 +127,92 @@ export function computePhysicalCohesion(root, files) {
|
|
|
126
127
|
};
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
function governedDestinationDir(anchor, concept) {
|
|
131
|
+
return !anchor || anchor === '.' ? concept : `${anchor}/${concept}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function anchorIsConsolidationSubtree(anchor, concept) {
|
|
135
|
+
return String(anchor).split('/').some((part) => part === concept);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function declaredSliceFolders(rules) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
142
|
+
const folders = Array.isArray(rule?.sliceFolders) ? rule.sliceFolders : [];
|
|
143
|
+
for (const folder of folders) {
|
|
144
|
+
if (typeof folder !== 'string' || folder.length === 0 || seen.has(folder.toLowerCase())) continue;
|
|
145
|
+
seen.add(folder.toLowerCase());
|
|
146
|
+
out.push(folder);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function destinationKeepsLayerAndSlice(fromRel, toRel, layers, sliceFolders) {
|
|
153
|
+
return layerForRelativePath(fromRel, layers) === layerForRelativePath(toRel, layers)
|
|
154
|
+
&& sliceIdForPath(fromRel, sliceFolders) === sliceIdForPath(toRel, sliceFolders);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withheldPilot(concept, note) {
|
|
158
|
+
return { proposed: true, applied: false, neverMechanicalSafe: true, concept, note, nextPilot: null };
|
|
159
|
+
}
|
|
160
|
+
|
|
129
161
|
/**
|
|
130
|
-
* R2 —
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* governed write path (T02 preflight validates every real move there).
|
|
162
|
+
* R2 — proposed reshape pilot (ADR 0010 D4–D7). `to` is `concept/` under the
|
|
163
|
+
* source anchor and is kept only when the generated layer and slice matchers
|
|
164
|
+
* agree. Tooling does not import Kernel preflight. Withheld when every
|
|
165
|
+
* candidate fails.
|
|
135
166
|
*/
|
|
136
|
-
export function computeReshapePilot(cohesion, files, root) {
|
|
167
|
+
export function computeReshapePilot(cohesion, files, root, contract) {
|
|
137
168
|
const top = cohesion?.findings?.[0];
|
|
138
169
|
if (!top) return null;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
// movable anchor sat below the display floor (cross-model review finding).
|
|
170
|
+
const layers = contract?.layers;
|
|
171
|
+
const sliceFolders = declaredSliceFolders(contract?.rules);
|
|
172
|
+
// Full anchor map: the finding's anchors are display-filtered (>= MIRROR_MIN).
|
|
143
173
|
const byAnchor = new Map();
|
|
144
174
|
const relOf = (abs) => path.relative(root, abs).split(path.sep).join('/');
|
|
145
|
-
|
|
175
|
+
const fileList = Array.isArray(files) ? files : [];
|
|
176
|
+
for (const abs of fileList) {
|
|
146
177
|
const rel = relOf(abs);
|
|
147
|
-
const
|
|
148
|
-
if (!
|
|
149
|
-
byAnchor.set(
|
|
178
|
+
const ranked = classifyPhysical(rel);
|
|
179
|
+
if (!ranked || ranked.concept !== top.concept) continue;
|
|
180
|
+
byAnchor.set(ranked.anchor, (byAnchor.get(ranked.anchor) ?? 0) + 1);
|
|
150
181
|
}
|
|
151
|
-
const targetDir = `src/features/${top.concept}`;
|
|
152
182
|
const movable = [...byAnchor.entries()]
|
|
153
|
-
.filter(
|
|
154
|
-
([anchor]) =>
|
|
155
|
-
!CONVENTION_ANCHOR_RE.test(`${anchor}/`) &&
|
|
156
|
-
// The consolidation target subtree is DONE, not a source — without
|
|
157
|
-
// this the loop re-proposes the files it just moved, forever
|
|
158
|
-
// (end-to-end pilot-loop finding).
|
|
159
|
-
anchor !== targetDir &&
|
|
160
|
-
!anchor.startsWith(`${targetDir}/`)
|
|
161
|
-
)
|
|
183
|
+
.filter(([anchor]) => !CONVENTION_ANCHOR_RE.test(`${anchor}/`) && !anchorIsConsolidationSubtree(anchor, top.concept))
|
|
162
184
|
.map(([anchor, count]) => ({ path: anchor, files: count }))
|
|
163
185
|
.sort((a, b) => a.files - b.files || (a.path < b.path ? -1 : 1));
|
|
164
186
|
if (movable.length === 0) {
|
|
165
|
-
return
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
return withheldPilot(top.concept, 'Every remaining anchor for this concept is fixed by framework convention or already consolidated — nothing to move; consider the merge-card review instead.');
|
|
188
|
+
}
|
|
189
|
+
const legalAnchors = [];
|
|
190
|
+
for (const anchor of movable) {
|
|
191
|
+
const targetDir = governedDestinationDir(anchor.path, top.concept);
|
|
192
|
+
const legal = fileList.map(relOf).filter((rel) => {
|
|
193
|
+
const ranked = classifyPhysical(rel);
|
|
194
|
+
if (!ranked || ranked.concept !== top.concept || ranked.anchor !== anchor.path) return false;
|
|
195
|
+
const to = `${targetDir}/${rel.split('/').at(-1)}`;
|
|
196
|
+
return destinationKeepsLayerAndSlice(rel, to, layers, sliceFolders);
|
|
197
|
+
}).sort().map((rel) => ({ from: rel, to: `${targetDir}/${rel.split('/').at(-1)}` }));
|
|
198
|
+
if (legal.length > 0) legalAnchors.push({ ...anchor, targetDir, legal });
|
|
199
|
+
}
|
|
200
|
+
if (legalAnchors.length === 0) {
|
|
201
|
+
return withheldPilot(top.concept, 'No governed destination keeps the layer and the slice.');
|
|
173
202
|
}
|
|
174
|
-
// Smallest
|
|
175
|
-
|
|
176
|
-
const pilotAnchor = movable.find((a) => a.files >= 10) ?? movable[movable.length - 1];
|
|
177
|
-
const rels = (Array.isArray(files) ? files : [])
|
|
178
|
-
.map(relOf)
|
|
179
|
-
.filter((rel) => {
|
|
180
|
-
const r = classifyPhysical(rel);
|
|
181
|
-
return r && r.concept === top.concept && r.anchor === pilotAnchor.path;
|
|
182
|
-
})
|
|
183
|
-
.sort();
|
|
184
|
-
const moves = rels.slice(0, MAX_MOVE_SAMPLE).map((rel) => ({
|
|
185
|
-
from: rel,
|
|
186
|
-
to: `${targetDir}/${rel.split('/').at(-1)}`,
|
|
187
|
-
}));
|
|
203
|
+
// Smallest cluster of at least 10 files; otherwise the largest movable anchor.
|
|
204
|
+
const pilotAnchor = legalAnchors.find((a) => a.files >= 10) ?? legalAnchors.at(-1);
|
|
188
205
|
return {
|
|
189
206
|
proposed: true,
|
|
190
207
|
applied: false,
|
|
191
208
|
neverMechanicalSafe: true,
|
|
192
209
|
concept: top.concept,
|
|
193
210
|
nextPilot: {
|
|
194
|
-
pilotTarget: `${top.concept} @ ${pilotAnchor.path} (${pilotAnchor.
|
|
195
|
-
move: `Consolidate the ${top.concept} cluster from ${pilotAnchor.path} under ${targetDir}/ — one anchor only, moves proposed as an architecture change map and validated by the atomic preflight before any write.`,
|
|
196
|
-
moveSample:
|
|
197
|
-
movesTotal:
|
|
198
|
-
successSignal: `re-run doctor: the ${top.concept} cluster count drops and
|
|
211
|
+
pilotTarget: `${top.concept} @ ${pilotAnchor.path} (${pilotAnchor.legal.length} file(s))`,
|
|
212
|
+
move: `Consolidate the ${top.concept} cluster from ${pilotAnchor.path} under ${pilotAnchor.targetDir}/ — one anchor only, same layer and slice, moves proposed as an architecture change map and validated by the atomic preflight before any write.`,
|
|
213
|
+
moveSample: pilotAnchor.legal.slice(0, MAX_MOVE_SAMPLE),
|
|
214
|
+
movesTotal: pilotAnchor.legal.length,
|
|
215
|
+
successSignal: `re-run doctor: the ${top.concept} cluster count drops, layer and slice id unchanged, card still proposed`,
|
|
199
216
|
killSwitch: 'revert this move set; nothing else was touched',
|
|
200
217
|
doNot: [
|
|
201
218
|
'never move files under app/ or pages/ — fixed by framework convention',
|
|
@@ -214,7 +214,7 @@ export function computeReshapeDecisionMemory(root, files, today = new Date().toI
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
/** Select one actionable finding while respecting explicit current verdicts. */
|
|
217
|
-
export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis) {
|
|
217
|
+
export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis, contract) {
|
|
218
218
|
const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
|
|
219
219
|
if (findings.length === 0) return null;
|
|
220
220
|
const currentByTarget = new Map(
|
|
@@ -224,7 +224,7 @@ export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis
|
|
|
224
224
|
const anchors = analysis.anchorSets.get(finding.concept) ?? [];
|
|
225
225
|
const decision = currentByTarget.get(targetKey(finding.concept, anchors));
|
|
226
226
|
if (decision?.suppressesPilot) continue;
|
|
227
|
-
const pilot = computeReshapePilot({ ...cohesion, findings: [finding] }, files, root);
|
|
227
|
+
const pilot = computeReshapePilot({ ...cohesion, findings: [finding] }, files, root, contract);
|
|
228
228
|
if (!pilot?.nextPilot) return pilot;
|
|
229
229
|
return {
|
|
230
230
|
...pilot,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
9
|
-
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
9
|
+
import { evaluateInvariantCoverage, formatCoverageDiscards } from './invariant-coverage.mjs';
|
|
10
10
|
import {
|
|
11
11
|
coverageOptionsFromConfig,
|
|
12
12
|
invariantIdsFromCatalog,
|
|
@@ -138,9 +138,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
138
138
|
fileContents: coverageInputs.fileContents,
|
|
139
139
|
testFiles: coverageInputs.testFiles,
|
|
140
140
|
testGlobsMissing: coverageInputs.testGlobsMissing,
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
141
|
+
// Stats shape the green doctor line (discard counts), not only a failing
|
|
142
|
+
// sentence. Roots stay out: this caller drops violations.
|
|
143
|
+
...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
|
|
144
144
|
coverageBudgetExhausted: coverageInputs.coverageBudgetExhausted === true,
|
|
145
145
|
});
|
|
146
146
|
const covById = new Map(
|
|
@@ -189,6 +189,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
189
189
|
const uncoveredTruncated = Math.max(0, uncoveredAll.length - UNCOVERED_CATALOG_MAX);
|
|
190
190
|
const uncovered = uncoveredAll.slice(0, UNCOVERED_CATALOG_MAX);
|
|
191
191
|
|
|
192
|
+
const symbolEvidence = (coverage.coverage ?? [])
|
|
193
|
+
.filter((row) => typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0)
|
|
194
|
+
.map((row) => ({ id: row.invariantId, file: row.symbolEvidenceFile }));
|
|
192
195
|
const coveredAll = (coverage.coverage ?? [])
|
|
193
196
|
.filter((row) => row.covered)
|
|
194
197
|
.map((row) => ({
|
|
@@ -196,6 +199,9 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
196
199
|
layer: row.layer ?? null,
|
|
197
200
|
mode: row.mode ?? null,
|
|
198
201
|
description: row.description ?? null,
|
|
202
|
+
...(typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0
|
|
203
|
+
? { symbolEvidenceFile: row.symbolEvidenceFile }
|
|
204
|
+
: {}),
|
|
199
205
|
}));
|
|
200
206
|
const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
|
|
201
207
|
const coveredSample = coveredAll.slice(0, COVERED_SAMPLE_MAX);
|
|
@@ -259,6 +265,8 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
259
265
|
uncoveredTruncated,
|
|
260
266
|
coveredSample,
|
|
261
267
|
coveredTruncated,
|
|
268
|
+
symbolEvidence,
|
|
269
|
+
...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
|
|
262
270
|
mergePlanes,
|
|
263
271
|
notAScore: true,
|
|
264
272
|
note: 'ArkRules plane (intra-layer) — counts and catalog, never a score. Green with uncovered residual must say so. Structure sensors are heuristics; invariants are catalog+coverage evidence, not a business runtime.',
|
|
@@ -280,6 +288,27 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
280
288
|
}
|
|
281
289
|
}
|
|
282
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Symbol witness paths and discard counts. Printed on green runs too —
|
|
293
|
+
* a passing check used to hide both. Depth and byte caps stay internal;
|
|
294
|
+
* only the counts already in the failure sentence are shown.
|
|
295
|
+
* @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
|
|
296
|
+
* @returns {string[]}
|
|
297
|
+
*/
|
|
298
|
+
export function formatArkRulesEvidenceLines(section) {
|
|
299
|
+
if (!section || typeof section !== 'object') return [];
|
|
300
|
+
const lines = [];
|
|
301
|
+
const evidence = Array.isArray(section.symbolEvidence) ? section.symbolEvidence : [];
|
|
302
|
+
for (const row of evidence) {
|
|
303
|
+
if (typeof row?.file !== 'string' || row.file.length === 0) continue;
|
|
304
|
+
const id = typeof row.id === 'string' && row.id.length > 0 ? row.id : 'invariant';
|
|
305
|
+
lines.push(`ArkRules: ${id} symbol ${row.file}`);
|
|
306
|
+
}
|
|
307
|
+
const discard = formatCoverageDiscards(section.coverageStats);
|
|
308
|
+
if (discard) lines.push(`ArkRules:${discard}`);
|
|
309
|
+
return lines;
|
|
310
|
+
}
|
|
311
|
+
|
|
283
312
|
/**
|
|
284
313
|
* Compact / details doctor lines. Empty when the map is off — absence is silent.
|
|
285
314
|
* @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
|
|
@@ -332,6 +361,7 @@ export function formatArkRulesDoctorLines(section) {
|
|
|
332
361
|
else if (typeof section.note === 'string' && /failed/i.test(section.note)) {
|
|
333
362
|
lines.push(`ArkRules: ${section.note}`);
|
|
334
363
|
}
|
|
364
|
+
lines.push(...formatArkRulesEvidenceLines(section));
|
|
335
365
|
return lines;
|
|
336
366
|
}
|
|
337
367
|
|
|
@@ -475,6 +505,7 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
475
505
|
<code>${escape(c.id)}</code>
|
|
476
506
|
<span class="tag">covered</span>
|
|
477
507
|
<span class="dim">· ${escape(c.layer || '?')}</span>
|
|
508
|
+
${c.symbolEvidenceFile ? `<span class="dim">· ${escape(c.symbolEvidenceFile)}</span>` : ''}
|
|
478
509
|
${c.description ? `<div class="msg">${escape(c.description)}</div>` : ''}
|
|
479
510
|
</li>`
|
|
480
511
|
)
|
|
@@ -528,6 +559,11 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
528
559
|
}
|
|
529
560
|
${uncoveredBlock}
|
|
530
561
|
${coveredBlock}
|
|
562
|
+
${
|
|
563
|
+
formatArkRulesEvidenceLines(section)
|
|
564
|
+
.map((line) => `<p class="muted" style="margin-top:.35rem;font-size:.84rem">${escape(line)}</p>`)
|
|
565
|
+
.join('')
|
|
566
|
+
}
|
|
531
567
|
${
|
|
532
568
|
coveredSample.length || uncovered.length
|
|
533
569
|
? `<p class="muted" style="margin-top:.65rem;font-size:.84rem">Covered = catalog evidence found (symbol and/or test title). Not a claim that business semantics are fully proven end-to-end.</p>`
|