arkgate 4.8.18 → 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 +153 -0
- package/README.md +5 -6
- package/bin/ark-check-runtime.mjs +23 -0
- package/bin/ark-layer-match.mjs +141 -9
- package/bin/ark-mcp-runtime.mjs +3 -3
- package/bin/ark-shared.mjs +187 -16
- package/bin/ark.mjs +23 -19
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/ci-and-commands.mjs +2 -2
- package/bin/lib/config-contract.mjs +1 -0
- package/bin/lib/doctor-advisories.mjs +2 -1
- package/bin/lib/doctor-human.mjs +2 -0
- package/bin/lib/doctor-next-actions.mjs +35 -0
- package/bin/lib/doctor-plan.mjs +8 -8
- package/bin/lib/enforcement-honesty.mjs +30 -2
- package/bin/lib/gate-files.mjs +22 -1
- package/bin/lib/host-support-matrix.mjs +1 -1
- package/bin/lib/html-report-depth.mjs +6 -2
- package/bin/lib/install-migrate.mjs +3 -4
- package/bin/lib/invariant-coverage.mjs +44 -13
- package/bin/lib/managed-upgrade.mjs +30 -5
- package/bin/lib/mcp-adoption.mjs +4 -1
- package/bin/lib/package-manager.mjs +5 -2
- package/bin/lib/physical-cohesion.mjs +68 -51
- package/bin/lib/presets.mjs +14 -14
- package/bin/lib/reshape-decisions.mjs +2 -2
- package/bin/lib/rules-under-contract.mjs +40 -4
- package/bin/lib/scan-files.mjs +3 -26
- package/bin/lib/skill-install.mjs +3 -2
- package/bin/lib/start-install-recovery.mjs +156 -0
- package/bin/lib/start-preview.mjs +17 -16
- package/bin/lib/write-path-detect.mjs +3 -2
- package/dist/{configTypes-VD0qcubY.d.ts → configTypes-VVknqcTJ.d.ts} +2 -0
- package/dist/{diagnosticCatalog-BNxKdcN4.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/agent-guide.md +3 -1
- package/docs/ai-gates.md +7 -3
- package/docs/configuration.md +20 -5
- package/docs/develop.md +1 -2
- package/docs/package-surface.md +2 -2
- package/docs/use.md +1 -2
- 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-place/SKILL.md +2 -0
- package/templates/agent-skills/ark-upgrade/SKILL.md +5 -3
- package/templates/hooks/opencode-ark-write-gate.mjs +2 -2
- 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-place.md +2 -0
- package/templates/skills/ark-upgrade.md +5 -3
|
@@ -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',
|
package/bin/lib/presets.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
11
11
|
discoverRepoUnits,
|
|
12
12
|
DEFAULT_INTENT_PREFIXES,
|
|
13
|
+
monorepoRoleSourceGlobs,
|
|
13
14
|
resolveIncludeRoots,
|
|
14
15
|
} from '../ark-shared.mjs';
|
|
15
16
|
import { withArkConfigMetadata } from './config-contract.mjs';
|
|
@@ -630,20 +631,18 @@ export const ARCHITECTURE_PRESETS = {
|
|
|
630
631
|
// Turborepo: apps/ + packages/; Nx enterprise: apps/ + libs/ (+ packages/).
|
|
631
632
|
if (include.length === 0) include = ['packages', 'apps', 'libs'];
|
|
632
633
|
// Library Domain bags stay under packages/ or libs/ — never api/** or client/**.
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const librarySourcePatterns = roleGlobs(['library'], true);
|
|
646
|
-
const applicationSourcePatterns = roleGlobs(['application', 'cli'], false);
|
|
634
|
+
const librarySourcePatterns = monorepoRoleSourceGlobs(units, ['library'], {
|
|
635
|
+
root,
|
|
636
|
+
domainScoped: true,
|
|
637
|
+
});
|
|
638
|
+
const applicationSourcePatterns = monorepoRoleSourceGlobs(units, ['application', 'cli'], {
|
|
639
|
+
root,
|
|
640
|
+
uiMode: 'not-ui',
|
|
641
|
+
});
|
|
642
|
+
const uiApplicationSourcePatterns = monorepoRoleSourceGlobs(units, ['application', 'cli'], {
|
|
643
|
+
root,
|
|
644
|
+
uiMode: 'ui',
|
|
645
|
+
});
|
|
647
646
|
return presetWithOverlays(
|
|
648
647
|
{
|
|
649
648
|
include,
|
|
@@ -689,6 +688,7 @@ export const ARCHITECTURE_PRESETS = {
|
|
|
689
688
|
'**/http/**',
|
|
690
689
|
'**/routes/**',
|
|
691
690
|
'**/hooks/**',
|
|
691
|
+
...uiApplicationSourcePatterns,
|
|
692
692
|
// No bare **/lib/** — NEW-ADOPT-LIB-AS-PRESENTATION / NEW-APP-VACUUM-LIB.
|
|
693
693
|
],
|
|
694
694
|
optional: true,
|
|
@@ -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>`
|
package/bin/lib/scan-files.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { isScanExcludedRelative } from '../ark-shared.mjs';
|
|
7
|
+
import { isScanExcludedRelative, isSkippedSourceDir } from '../ark-shared.mjs';
|
|
8
|
+
|
|
9
|
+
export { isSkippedSourceDir };
|
|
8
10
|
|
|
9
11
|
export const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
|
|
10
12
|
|
|
@@ -18,31 +20,6 @@ export function isGovernableSourceFile(name) {
|
|
|
18
20
|
return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
export function isSkippedSourceDir(name) {
|
|
22
|
-
return (
|
|
23
|
-
name === 'node_modules' ||
|
|
24
|
-
name === 'dist' ||
|
|
25
|
-
name === 'coverage' ||
|
|
26
|
-
name === 'bench' ||
|
|
27
|
-
name === 'benches' ||
|
|
28
|
-
name === 'benchmark' ||
|
|
29
|
-
name === 'benchmarks' ||
|
|
30
|
-
name === 'docs' ||
|
|
31
|
-
name === 'documentation' ||
|
|
32
|
-
name === 'example' ||
|
|
33
|
-
name === 'examples' ||
|
|
34
|
-
name === 'fixture' ||
|
|
35
|
-
name === 'fixtures' ||
|
|
36
|
-
name === 'playground' ||
|
|
37
|
-
name === '__tests__' ||
|
|
38
|
-
name === '__mocks__' ||
|
|
39
|
-
name === 'e2e' ||
|
|
40
|
-
// Top-level style Nest/Jest folders (not "testing" helpers inside src)
|
|
41
|
-
name === 'test' ||
|
|
42
|
-
name === 'tests'
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
23
|
function isInsideRoot(root, target) {
|
|
47
24
|
const rel = path.relative(root, target);
|
|
48
25
|
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
@@ -486,8 +486,9 @@ export function skillContentMatchesTemplate(installedContent, templateContent) {
|
|
|
486
486
|
* Repo catalogs belong to that repo's installed package, so an explicit --force
|
|
487
487
|
* may move them in either direction. Codex home is shared by every repo on the
|
|
488
488
|
* machine: a package older than the installed home stamp must never win, even
|
|
489
|
-
* under --force. In both scopes a version-stamp-only difference is
|
|
490
|
-
*
|
|
489
|
+
* under --force. In both scopes a version-stamp-only difference is
|
|
490
|
+
* `stamp-refresh` (write without --force) so the picker shows this package;
|
|
491
|
+
* the skill body is the capability contract.
|
|
491
492
|
*
|
|
492
493
|
* @param {{
|
|
493
494
|
* existingContent?: string|null,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classified next steps when start / start --apply package install fails.
|
|
3
|
+
*
|
|
4
|
+
* Callers render `formatStartPackageInstallFailure`. Classification, Age window,
|
|
5
|
+
* pin-aware npx, and AgeExclude-must-be-in-config stay behind this module.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { arkPackageRecoveryCommand } from './package-manager.mjs';
|
|
9
|
+
|
|
10
|
+
const AGE_MARK = /ERR_PNPM_NO_MATURE_MATCHING_VERSION|minimumReleaseAge/i;
|
|
11
|
+
const AGE_EXCLUDE_LINE =
|
|
12
|
+
'To add it locally later: put arkgate on minimumReleaseAgeExclude in pnpm config or pnpm-workspace.yaml (a CLI flag is not enough), or wait until the release is old enough.';
|
|
13
|
+
const BIN_NOTE =
|
|
14
|
+
'`arkgate-check` is a command in the arkgate package — not its own npm package.';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} hostOutput
|
|
18
|
+
* @returns {string[]}
|
|
19
|
+
*/
|
|
20
|
+
function hostOutputTail(hostOutput) {
|
|
21
|
+
if (!hostOutput || !String(hostOutput).trim()) return [];
|
|
22
|
+
return String(hostOutput)
|
|
23
|
+
.split('\n')
|
|
24
|
+
.map((line) => line.trimEnd())
|
|
25
|
+
.filter((line) => line.trim())
|
|
26
|
+
.slice(-6);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Exact npm version for `npx --package=arkgate@<pin>` when Age blocked add.
|
|
31
|
+
* @param {string|undefined} packageVersion
|
|
32
|
+
* @param {string|undefined} installCommand
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
export function resolveStartInstallPackagePin(packageVersion, installCommand) {
|
|
36
|
+
const fromArg = String(packageVersion ?? '')
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/^arkgate@/i, '')
|
|
39
|
+
.replace(/^\^/, '')
|
|
40
|
+
.replace(/^v/i, '');
|
|
41
|
+
if (/^\d+\.\d+\.\d+/.test(fromArg)) return fromArg.match(/^\d+\.\d+\.\d+/)?.[0] ?? null;
|
|
42
|
+
const fromCmd = String(installCommand ?? '').match(/arkgate@\^?v?(\d+\.\d+\.\d+)/i);
|
|
43
|
+
return fromCmd?.[1] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {{ kind: 'pnpm-age', ageWindow: string, headline: string }} PnpmAgeInstallFailure
|
|
48
|
+
* @typedef {{ kind: 'generic', hostTail: string[] }} GenericInstallFailure
|
|
49
|
+
* @typedef {PnpmAgeInstallFailure | GenericInstallFailure} ClassifiedStartInstallFailure
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Classify captured package-manager stdout+stderr. Age is one class.
|
|
54
|
+
* @param {unknown} hostOutput
|
|
55
|
+
* @returns {ClassifiedStartInstallFailure}
|
|
56
|
+
*/
|
|
57
|
+
export function classifyStartInstallFailure(hostOutput) {
|
|
58
|
+
const text = String(hostOutput ?? '');
|
|
59
|
+
if (AGE_MARK.test(text)) {
|
|
60
|
+
const days = text.match(/(\d+)\s*days?/i)?.[1];
|
|
61
|
+
const ageWindow = days ? `${days} days` : 'a cooling-off window';
|
|
62
|
+
const headline = days
|
|
63
|
+
? `This repo waits before trusting new npm packages (pnpm Age — ${days} days).`
|
|
64
|
+
: 'This repo waits before trusting new npm packages (pnpm Age).';
|
|
65
|
+
return { kind: 'pnpm-age', ageWindow, headline };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 'generic', hostTail: hostOutputTail(text) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Plain Age headline, or null. Kept for the #268 one-liner tests.
|
|
72
|
+
* @param {unknown} hostOutput
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function explainPnpmMaturityBlock(hostOutput) {
|
|
76
|
+
const classified = classifyStartInstallFailure(hostOutput);
|
|
77
|
+
return classified.kind === 'pnpm-age' ? classified.headline : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {{
|
|
82
|
+
* kind: 'pnpm-age' | 'generic',
|
|
83
|
+
* ageWindow?: string,
|
|
84
|
+
* primaryCommand: string,
|
|
85
|
+
* replayInstallCommand: string | null,
|
|
86
|
+
* lines: string[],
|
|
87
|
+
* }} StartInstallRecovery
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Typed next-step for a failed start package install.
|
|
92
|
+
* Age never replays the doomed add; generic still names the exact command.
|
|
93
|
+
* @param {{
|
|
94
|
+
* exitStatus: number,
|
|
95
|
+
* installCommand: string,
|
|
96
|
+
* hostOutput?: string,
|
|
97
|
+
* packageVersion?: string,
|
|
98
|
+
* }} input
|
|
99
|
+
* @returns {StartInstallRecovery}
|
|
100
|
+
*/
|
|
101
|
+
export function startInstallRecovery(input) {
|
|
102
|
+
const exitStatus = input?.exitStatus ?? 1;
|
|
103
|
+
const installCommand = input?.installCommand ?? '';
|
|
104
|
+
const classified = classifyStartInstallFailure(input?.hostOutput);
|
|
105
|
+
const pin = resolveStartInstallPackagePin(input?.packageVersion, installCommand);
|
|
106
|
+
const honesty = `Package install failed (exit ${exitStatus}). Setup files are written; the local command is not installed yet.`;
|
|
107
|
+
if (classified.kind === 'pnpm-age') {
|
|
108
|
+
const doctor = arkPackageRecoveryCommand(
|
|
109
|
+
'arkgate-check',
|
|
110
|
+
'--doctor',
|
|
111
|
+
pin ? `arkgate@${pin}` : undefined
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
kind: 'pnpm-age',
|
|
115
|
+
ageWindow: classified.ageWindow,
|
|
116
|
+
primaryCommand: doctor,
|
|
117
|
+
replayInstallCommand: null,
|
|
118
|
+
lines: [classified.headline, honesty, ` ${doctor}`, BIN_NOTE, AGE_EXCLUDE_LINE],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const doctor = arkPackageRecoveryCommand('arkgate-check', '--doctor');
|
|
122
|
+
return {
|
|
123
|
+
kind: 'generic',
|
|
124
|
+
primaryCommand: installCommand,
|
|
125
|
+
replayInstallCommand: installCommand,
|
|
126
|
+
lines: [...classified.hostTail, honesty, ` ${installCommand}`, ` ${doctor}`, BIN_NOTE],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Red next-step when post-apply package install fails.
|
|
132
|
+
* Host files may already be written; local bins may be missing.
|
|
133
|
+
* @param {{
|
|
134
|
+
* exitStatus: number,
|
|
135
|
+
* installCommand: string,
|
|
136
|
+
* hostOutput?: string,
|
|
137
|
+
* packageVersion?: string,
|
|
138
|
+
* }} input
|
|
139
|
+
*/
|
|
140
|
+
export function formatStartPackageInstallFailure(input) {
|
|
141
|
+
return startInstallRecovery(input).lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Pipe the package manager so recovery can hide maturity internals. */
|
|
145
|
+
export function runStartPackageInstall(command, commandArgs, cwd) {
|
|
146
|
+
const result = spawnSync(command, commandArgs, {
|
|
147
|
+
cwd,
|
|
148
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
status: result.status ?? 1,
|
|
153
|
+
stdout: result.stdout ?? '',
|
|
154
|
+
stderr: result.stderr ?? '',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -5,7 +5,6 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
arkCommand,
|
|
8
|
-
arkPackageRecoveryCommand,
|
|
9
8
|
buildArchitectureRecommendation,
|
|
10
9
|
START_APPLY_REFUSE_FOOTER,
|
|
11
10
|
} from '../ark-shared.mjs';
|
|
@@ -40,8 +39,8 @@ const COMPACT_HOST_TEMPLATES = {
|
|
|
40
39
|
['.mcp.json', mcpJson(root)],
|
|
41
40
|
],
|
|
42
41
|
cursor: (root) => [
|
|
43
|
-
['.cursor/mcp.json', mcpJson(root)],
|
|
44
42
|
['.cursor/hooks.json', cursorHooks(root)],
|
|
43
|
+
['.cursor/mcp.json', mcpJson(root)],
|
|
45
44
|
],
|
|
46
45
|
codex: (root) => [
|
|
47
46
|
['.codex/hooks.json', codexHooks(root)],
|
|
@@ -83,6 +82,17 @@ function digest(content) {
|
|
|
83
82
|
return `sha256:${crypto.createHash('sha256').update(content ?? Buffer.alloc(0)).digest('hex')}`;
|
|
84
83
|
}
|
|
85
84
|
|
|
85
|
+
function writeGateOrderGuarantee(tools) {
|
|
86
|
+
const host = typeof tools === 'string' ? tools.trim().toLowerCase() : '';
|
|
87
|
+
if (host === 'opencode') {
|
|
88
|
+
return 'Write gate: MCP prepare only (this host has no hard pre-hook). Required CI is the merge line.';
|
|
89
|
+
}
|
|
90
|
+
if (['claude', 'grok', 'antigravity', 'cursor', 'codex'].includes(host)) {
|
|
91
|
+
return 'Write gate: host pre-hook first; MCP prepare is fallback if that hook is missing or fail-open.';
|
|
92
|
+
}
|
|
93
|
+
return 'Write gate: host pre-hook first when the host has one; MCP prepare is fallback. Required CI is the merge line.';
|
|
94
|
+
}
|
|
95
|
+
|
|
86
96
|
function change(pathname, before, after) {
|
|
87
97
|
return {
|
|
88
98
|
path: pathname,
|
|
@@ -207,20 +217,6 @@ function commands(root, args, helpers) {
|
|
|
207
217
|
return result;
|
|
208
218
|
}
|
|
209
219
|
|
|
210
|
-
/**
|
|
211
|
-
* Red next-step when post-apply package install fails.
|
|
212
|
-
* Host files may already be written; local bins may be missing.
|
|
213
|
-
*/
|
|
214
|
-
export function formatStartPackageInstallFailure({ exitStatus, installCommand }) {
|
|
215
|
-
const doctor = arkPackageRecoveryCommand('arkgate-check', '--doctor');
|
|
216
|
-
return [
|
|
217
|
-
`Package install failed (exit ${exitStatus}). Setup files are written; the local command is not installed yet.`,
|
|
218
|
-
` ${installCommand}`,
|
|
219
|
-
` ${doctor}`,
|
|
220
|
-
'`arkgate-check` is a command in the arkgate package — not its own npm package.',
|
|
221
|
-
].join('\n');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
220
|
/**
|
|
225
221
|
* @param {object} preview
|
|
226
222
|
* @param {{ applying?: boolean }} [options] when applying=true, do not claim “no files were changed”
|
|
@@ -263,6 +259,10 @@ export function renderStartPreview(preview, options = {}) {
|
|
|
263
259
|
if (preview.runtimeActivation) {
|
|
264
260
|
console.log('Host: Codex is configured but not verified yet. Restart the host, then confirm this project.');
|
|
265
261
|
}
|
|
262
|
+
const writeGateOrder = (preview.hostGuarantees || []).find((line) =>
|
|
263
|
+
String(line).startsWith('Write gate:')
|
|
264
|
+
);
|
|
265
|
+
if (writeGateOrder) console.log(writeGateOrder);
|
|
266
266
|
if (preview.unresolvedDecisions.length > 0) {
|
|
267
267
|
console.log('Unresolved decisions:');
|
|
268
268
|
for (const decision of preview.unresolvedDecisions) console.log(` ${decision}`);
|
|
@@ -503,6 +503,7 @@ export async function planStart(args, helpers) {
|
|
|
503
503
|
commands: commands(root, args, helpers),
|
|
504
504
|
hostGuarantees: [
|
|
505
505
|
args.requireWriteHook ? `Hard-write hook verified for ${args.requireWriteHook}` : 'shared CI merge gate will be installed',
|
|
506
|
+
writeGateOrderGuarantee(args.tools),
|
|
506
507
|
'preview phase performs no writes in the target project',
|
|
507
508
|
'apply writes the exact bytes identified by each afterHash',
|
|
508
509
|
...(codexSelected
|
|
@@ -141,8 +141,9 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
|
141
141
|
? 'OpenCode local write is advisory (MCP + optional experimental plugin — not a hard boundary; ' +
|
|
142
142
|
'not equivalent to Claude/Grok/Antigravity PreToolUse hard-write). ' +
|
|
143
143
|
'The hard merge backstop is a required GitHub status context running --strict-merge.'
|
|
144
|
-
: `
|
|
145
|
-
'
|
|
144
|
+
: `Fell back to MCP prepare: active host ${activeHost} has no hard pre-hook. ` +
|
|
145
|
+
'Advisory prepare-write/autoPatch is the local write surface. ' +
|
|
146
|
+
'Next: install and trust the host write hook, or keep required CI as the merge line.';
|
|
146
147
|
gap = {
|
|
147
148
|
id: 'write-path-mcp-only',
|
|
148
149
|
severity: 'info',
|
|
@@ -60,6 +60,8 @@ type ArkConfigRule = {
|
|
|
60
60
|
sharedRoots?: string[];
|
|
61
61
|
/** Directed slice→slice edges the repo declares on purpose. */
|
|
62
62
|
allowedCrossSlice?: ArkConfigCrossSliceEdge[];
|
|
63
|
+
/** When `"deny"`, a declared shared root may not import a slice. */
|
|
64
|
+
sharedImportsSlice?: 'deny';
|
|
63
65
|
};
|
|
64
66
|
type ArkConfigCrossSliceEdge = {
|
|
65
67
|
from: string;
|