arkgate 4.8.23 → 4.8.24
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 +68 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +24 -77
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +596 -112
- package/bin/ark-mcp-runtime.mjs +412 -138
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +39 -0
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/agent-projection-command.mjs +5 -1
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +50 -6
- package/bin/lib/ark-order-doctor.mjs +1 -1
- package/bin/lib/ark-order-facts.mjs +410 -15
- package/bin/lib/ark-order-invariants.mjs +51 -9
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +2 -0
- package/bin/lib/ark-run-doctor.mjs +14 -0
- package/bin/lib/ark-run-facts.mjs +434 -11
- package/bin/lib/ark-run-sensors.mjs +5 -3
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrules-sensors.mjs +50 -249
- package/bin/lib/check-args.mjs +23 -1
- package/bin/lib/ci-and-commands.mjs +16 -6
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/codex-home.mjs +4 -2
- package/bin/lib/config-contract-slices.mjs +701 -0
- package/bin/lib/config-contract.mjs +5 -493
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/diagnostic-catalog.mjs +16 -9
- package/bin/lib/doctor-advisories.mjs +25 -1
- package/bin/lib/doctor-next-actions.mjs +17 -2
- package/bin/lib/doctor-plan.mjs +6 -8
- package/bin/lib/effective-contract-load.mjs +121 -59
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/first-run-help.mjs +3 -2
- package/bin/lib/flat-parent-importers.mjs +137 -0
- package/bin/lib/flat-parent-pilot.mjs +40 -100
- package/bin/lib/gate-files.mjs +29 -2
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +1 -1
- package/bin/lib/hook-templates.mjs +18 -16
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage.mjs +59 -11
- package/bin/lib/mcp-adoption.mjs +9 -1
- package/bin/lib/mcp-hook-payload.mjs +401 -61
- package/bin/lib/package-manager.mjs +59 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +81 -0
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +168 -10
- package/bin/lib/prepare-change.mjs +120 -6
- package/bin/lib/prepare-write.mjs +1 -0
- package/bin/lib/presets.mjs +3 -5
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +315 -55
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +179 -5
- package/bin/lib/scan-files.mjs +32 -0
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/stable-hash.mjs +90 -0
- package/bin/lib/start-install-recovery.mjs +71 -1
- package/bin/lib/start-preview.mjs +30 -0
- package/bin/lib/status-command.mjs +33 -4
- package/bin/lib/status-manifest.mjs +32 -4
- package/bin/lib/team-parliament-io.mjs +77 -75
- package/bin/lib/team-parliament.mjs +6 -1
- package/bin/lib/write-path-capabilities.mjs +4 -2
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-MBXCFLO4.js +16 -0
- package/dist/chunk-RNQHRDCQ.cjs +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
- package/dist/configTypes-BJbaeoj4.d.ts +246 -0
- package/dist/eslint/index.cjs +17 -4
- package/dist/eslint/index.d.cts +359 -0
- package/dist/eslint/index.d.ts +130 -156
- package/dist/eslint/index.js +10 -4
- package/dist/index.cjs +35 -34
- package/dist/index.d.cts +5475 -0
- package/dist/index.d.ts +3312 -299
- package/dist/index.js +35 -34
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
- package/dist/runtime/index.d.ts +2678 -108
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
- package/dist/types-DRinG-vT.d.ts +1792 -0
- package/docs/README.md +2 -1
- package/docs/agent-guide.md +84 -15
- package/docs/ai-gates.md +160 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +196 -26
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +96 -19
- package/docs/package-surface.md +31 -8
- package/docs/product-voice.md +1 -1
- package/package.json +59 -18
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +47 -10
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +4 -1
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-runtime.md +6 -1
- package/dist/types-Ik_OakcI.d.ts +0 -183
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { DOMAIN_EVENTS_PUSH_RE, DOMAIN_INVARIANT_WORD_RE, expectedDomainInvariantWordsPhrase, isIdiomaticEventsReset, } from './arkrules-sensors.mjs';
|
|
12
|
+
/** Baseline key belongs to the ArkRules plane (structure sensors or invariants). */
|
|
13
|
+
export function isArkRulesFrozenKey(key) {
|
|
14
|
+
const ruleId = key.split('|', 1)[0] ?? '';
|
|
15
|
+
return ruleId.startsWith('ARKRULE_') || ruleId.startsWith('INVARIANT_');
|
|
16
|
+
}
|
|
12
17
|
function lineOf(content, index) {
|
|
13
18
|
return content.slice(0, index).split('\n').length;
|
|
14
19
|
}
|
|
@@ -274,13 +279,27 @@ export function buildRulesInventory(input) {
|
|
|
274
279
|
a.line - b.line ||
|
|
275
280
|
a.kind.localeCompare(b.kind) ||
|
|
276
281
|
a.id.localeCompare(b.id));
|
|
277
|
-
const
|
|
278
|
-
|
|
282
|
+
const contractedSensors = new Set((input.contractedStructure ?? []).map((rule) => `${rule.layer}\u0000${rule.sensor}`));
|
|
283
|
+
const underContract = candidates.filter((c) => {
|
|
284
|
+
const suggestion = c.suggestedArkRule;
|
|
285
|
+
if (!suggestion)
|
|
286
|
+
return false;
|
|
287
|
+
if (suggestion.invariantId && contracted.has(suggestion.invariantId))
|
|
288
|
+
return true;
|
|
289
|
+
if (!suggestion.structureId)
|
|
290
|
+
return false;
|
|
291
|
+
// Structure suggestions: the sensor on that layer is what enforces, not the id string.
|
|
292
|
+
if (suggestion.sensor &&
|
|
293
|
+
contractedSensors.has(`${suggestion.layer}\u0000${suggestion.sensor}`)) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
return contracted.has(suggestion.structureId);
|
|
297
|
+
}).length;
|
|
279
298
|
return {
|
|
280
299
|
candidates,
|
|
281
300
|
inventoried: candidates.length,
|
|
282
301
|
underContract,
|
|
283
|
-
frozen: (input.frozenKeys ?? []).length,
|
|
302
|
+
frozen: (input.frozenKeys ?? []).filter(isArkRulesFrozenKey).length,
|
|
284
303
|
notAScore: true,
|
|
285
304
|
};
|
|
286
305
|
}
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* Summary includes per-layer + structure/invariant detail so showcase HTML /ark-explain
|
|
5
5
|
* can teach what is under contract, not only aggregate numbers.
|
|
6
6
|
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
10
|
+
import { buildRulesInventory, inventoryToExtractionCard } from './rules-inventory.mjs';
|
|
11
|
+
import { collectGovernedFiles } from './scan-files.mjs';
|
|
12
|
+
import { readBaseline } from './violations.mjs';
|
|
9
13
|
import { evaluateInvariantCoverage, formatCoverageDiscards } from './invariant-coverage.mjs';
|
|
10
14
|
import {
|
|
11
15
|
coverageOptionsFromConfig,
|
|
@@ -18,7 +22,7 @@ import {
|
|
|
18
22
|
demoteExtraPlaneTeethUnderClassificationFloor,
|
|
19
23
|
} from './extra-merge-teeth.mjs';
|
|
20
24
|
import { collectEmptyInvariantCatalogFindings } from './arkrules-sensors.mjs';
|
|
21
|
-
import { layerForRelativePath } from '../ark-layer-match.mjs';
|
|
25
|
+
import { layerForFile, layerForRelativePath } from '../ark-layer-match.mjs';
|
|
22
26
|
import {
|
|
23
27
|
ARKRULES_EMPTY_CATALOG_NEXT,
|
|
24
28
|
ARKRULES_FIRST_CONTACT_NEXT,
|
|
@@ -96,9 +100,26 @@ function arkOrderMergeInput(config, residualCount = 0) {
|
|
|
96
100
|
};
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
/** ADR 0012 D2 drift: arkrules/*.json no arkRules entry references (advisory). */
|
|
104
|
+
function unreferencedArkRulesFiles(loaded) {
|
|
105
|
+
const files = (loaded?.warnings ?? []).map((w) => w.path).filter(Boolean);
|
|
106
|
+
return files.length > 0 ? { unreferencedFiles: files } : {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {{ rulesMigration?: boolean }} [options] rulesMigration:false skips the
|
|
111
|
+
* migration counts (the inventory payload computes them itself).
|
|
112
|
+
*/
|
|
113
|
+
export function summarizeRulesUnderContract(root, config, facts, classification, options = {}) {
|
|
100
114
|
if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
|
|
115
|
+
let drift = {};
|
|
116
|
+
try {
|
|
117
|
+
drift = unreferencedArkRulesFiles(loadEffectiveArkRulesFromDisk(root, config));
|
|
118
|
+
} catch {
|
|
119
|
+
drift = {};
|
|
120
|
+
}
|
|
101
121
|
return {
|
|
122
|
+
...drift,
|
|
102
123
|
active: false,
|
|
103
124
|
structureRules: 0,
|
|
104
125
|
invariants: 0,
|
|
@@ -191,7 +212,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
191
212
|
|
|
192
213
|
const symbolEvidence = (coverage.coverage ?? [])
|
|
193
214
|
.filter((row) => typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0)
|
|
194
|
-
.map((row) => ({ id: row.invariantId, file: row.symbolEvidenceFile }));
|
|
215
|
+
.map((row) => ({ id: row.invariantId, file: projectRelativePath(root, row.symbolEvidenceFile) }));
|
|
195
216
|
const coveredAll = (coverage.coverage ?? [])
|
|
196
217
|
.filter((row) => row.covered)
|
|
197
218
|
.map((row) => ({
|
|
@@ -200,7 +221,7 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
200
221
|
mode: row.mode ?? null,
|
|
201
222
|
description: row.description ?? null,
|
|
202
223
|
...(typeof row.symbolEvidenceFile === 'string' && row.symbolEvidenceFile.length > 0
|
|
203
|
-
? { symbolEvidenceFile: row.symbolEvidenceFile }
|
|
224
|
+
? { symbolEvidenceFile: projectRelativePath(root, row.symbolEvidenceFile) }
|
|
204
225
|
: {}),
|
|
205
226
|
}));
|
|
206
227
|
const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
|
|
@@ -266,6 +287,14 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
|
|
|
266
287
|
coveredSample,
|
|
267
288
|
coveredTruncated,
|
|
268
289
|
symbolEvidence,
|
|
290
|
+
...unreferencedArkRulesFiles(loaded),
|
|
291
|
+
// AR15: inventoried / under contract / frozen — same helper as --rules-inventory.
|
|
292
|
+
...(options.rulesMigration === false
|
|
293
|
+
? {}
|
|
294
|
+
: {
|
|
295
|
+
rulesMigration: buildRulesMigration({ root, config, arkRules: loaded.arkRules })
|
|
296
|
+
.rulesMigration,
|
|
297
|
+
}),
|
|
269
298
|
...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
|
|
270
299
|
mergePlanes,
|
|
271
300
|
notAScore: true,
|
|
@@ -314,9 +343,19 @@ export function formatArkRulesEvidenceLines(section) {
|
|
|
314
343
|
* @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
|
|
315
344
|
* @returns {string[]}
|
|
316
345
|
*/
|
|
346
|
+
function unreferencedFilesLine(section) {
|
|
347
|
+
const files = Array.isArray(section?.unreferencedFiles) ? section.unreferencedFiles : [];
|
|
348
|
+
if (files.length === 0) return [];
|
|
349
|
+
const shown = files.slice(0, 3).join(', ');
|
|
350
|
+
const more = files.length > 3 ? ` (+${files.length - 3} more)` : '';
|
|
351
|
+
return [
|
|
352
|
+
`ArkRules: ${shown}${more} not referenced by arkRules — nothing in it is enforced (ARKRULE_FILE_UNREFERENCED, advisory).`,
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
317
356
|
export function formatArkRulesDoctorLines(section) {
|
|
318
357
|
if (!section || typeof section !== 'object') return [];
|
|
319
|
-
if (section.active !== true) return
|
|
358
|
+
if (section.active !== true) return unreferencedFilesLine(section);
|
|
320
359
|
|
|
321
360
|
if (Array.isArray(section.loadErrors) && section.loadErrors.length > 0) {
|
|
322
361
|
const first = section.loadErrors[0];
|
|
@@ -362,6 +401,10 @@ export function formatArkRulesDoctorLines(section) {
|
|
|
362
401
|
lines.push(`ArkRules: ${section.note}`);
|
|
363
402
|
}
|
|
364
403
|
lines.push(...formatArkRulesEvidenceLines(section));
|
|
404
|
+
if (section.rulesMigration) {
|
|
405
|
+
lines.push(`ArkRules migration: ${formatRulesMigrationCounts(section.rulesMigration)}.`);
|
|
406
|
+
}
|
|
407
|
+
lines.push(...unreferencedFilesLine(section));
|
|
365
408
|
return lines;
|
|
366
409
|
}
|
|
367
410
|
|
|
@@ -559,6 +602,11 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
559
602
|
}
|
|
560
603
|
${uncoveredBlock}
|
|
561
604
|
${coveredBlock}
|
|
605
|
+
${
|
|
606
|
+
section.rulesMigration
|
|
607
|
+
? `<p class="muted" style="margin-top:.35rem;font-size:.84rem">Rules migration: ${escape(formatRulesMigrationCounts(section.rulesMigration))}</p>`
|
|
608
|
+
: ''
|
|
609
|
+
}
|
|
562
610
|
${
|
|
563
611
|
formatArkRulesEvidenceLines(section)
|
|
564
612
|
.map((line) => `<p class="muted" style="margin-top:.35rem;font-size:.84rem">${escape(line)}</p>`)
|
|
@@ -572,3 +620,129 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
572
620
|
${note}
|
|
573
621
|
</section>`;
|
|
574
622
|
}
|
|
623
|
+
|
|
624
|
+
export const RULES_INVENTORY_MAX_FILES = 400;
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Brownfield rules migration (AR13/AR15): one code path for `--rules-inventory`,
|
|
628
|
+
* MCP `ark_rules_inventory`, doctor, and the HTML report. Honest counts, never a score:
|
|
629
|
+
* - inventoried: candidates the heuristic inventory found (first 400 governed files);
|
|
630
|
+
* - underContract: candidates whose suggested ArkRule is declared — structure
|
|
631
|
+
* suggestions by (layer, sensor), invariant suggestions by id;
|
|
632
|
+
* - frozen: ArkRules-plane keys (`ARKRULE_*` / `INVARIANT_*`) in .ark-baseline.json.
|
|
633
|
+
* @param {{ root: string, config: Record<string, any>, files?: string[], arkRules?: any }} input
|
|
634
|
+
* `files` defaults to the governed walk; `arkRules` to the catalog on disk.
|
|
635
|
+
*/
|
|
636
|
+
export function buildRulesMigration({ root, config, files, arkRules }) {
|
|
637
|
+
const governed = Array.isArray(files) ? files : collectGovernedFiles(root, config);
|
|
638
|
+
const fileContents = {};
|
|
639
|
+
const fileLayers = {};
|
|
640
|
+
for (const file of governed.slice(0, RULES_INVENTORY_MAX_FILES)) {
|
|
641
|
+
const absolute = path.isAbsolute(file) ? file : path.resolve(root, file);
|
|
642
|
+
const rel = path.relative(root, absolute).split(path.sep).join('/');
|
|
643
|
+
try {
|
|
644
|
+
fileContents[rel] = fs.readFileSync(absolute, 'utf8');
|
|
645
|
+
const layer = layerForFile(root, absolute, config.layers);
|
|
646
|
+
if (layer) fileLayers[rel] = layer;
|
|
647
|
+
} catch {
|
|
648
|
+
/* skip unreadable */
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
let catalog = arkRules;
|
|
652
|
+
if (catalog === undefined && config?.arkRules) {
|
|
653
|
+
try {
|
|
654
|
+
const loaded = loadEffectiveArkRulesFromDisk(root, config);
|
|
655
|
+
catalog = loaded.errors.length > 0 ? null : loaded.arkRules;
|
|
656
|
+
} catch {
|
|
657
|
+
catalog = null; // the advisory inventory is still useful
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const contractedRuleIds = [];
|
|
661
|
+
const contractedStructure = [];
|
|
662
|
+
for (const rule of catalog?.structure ?? []) {
|
|
663
|
+
contractedRuleIds.push(rule.id);
|
|
664
|
+
const layer = rule.provenance?.layer ?? rule.layer;
|
|
665
|
+
if (layer && rule.sensor) contractedStructure.push({ layer, sensor: rule.sensor });
|
|
666
|
+
}
|
|
667
|
+
for (const inv of catalog?.invariants ?? []) contractedRuleIds.push(inv.id);
|
|
668
|
+
let frozenKeys = [];
|
|
669
|
+
try {
|
|
670
|
+
frozenKeys = [...readBaseline(root, '.ark-baseline.json').keys];
|
|
671
|
+
} catch {
|
|
672
|
+
frozenKeys = []; // an unreadable baseline freezes nothing
|
|
673
|
+
}
|
|
674
|
+
const inventory = buildRulesInventory({
|
|
675
|
+
fileContents,
|
|
676
|
+
fileLayers,
|
|
677
|
+
layerContexts: (config.layers ?? []).map((layer) => ({
|
|
678
|
+
name: layer.name,
|
|
679
|
+
intentPrefixes: layer.intentPrefixes ?? [],
|
|
680
|
+
})),
|
|
681
|
+
contractedRuleIds,
|
|
682
|
+
contractedStructure,
|
|
683
|
+
frozenKeys,
|
|
684
|
+
});
|
|
685
|
+
return {
|
|
686
|
+
inventory,
|
|
687
|
+
rulesMigration: {
|
|
688
|
+
inventoried: inventory.inventoried,
|
|
689
|
+
underContract: inventory.underContract,
|
|
690
|
+
frozen: inventory.frozen,
|
|
691
|
+
notAScore: true,
|
|
692
|
+
},
|
|
693
|
+
nextPilot:
|
|
694
|
+
inventory.candidates[0] != null ? inventoryToExtractionCard(inventory.candidates[0]) : null,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** `N inventoried, N under contract, N frozen (not a score)` — shared CLI/doctor wording. */
|
|
699
|
+
export function formatRulesMigrationCounts(rulesMigration) {
|
|
700
|
+
if (!rulesMigration || typeof rulesMigration !== 'object') return '';
|
|
701
|
+
return `${rulesMigration.inventoried} inventoried, ${rulesMigration.underContract} under contract, ${rulesMigration.frozen} frozen (not a score)`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Full `--rules-inventory` / `ark_rules_inventory` payload (same keys on both surfaces).
|
|
706
|
+
* @param {string} root
|
|
707
|
+
* @param {Record<string, any>} config
|
|
708
|
+
* @param {string[]} [files] governed files (absolute or root-relative)
|
|
709
|
+
*/
|
|
710
|
+
export function buildRulesInventoryPayload(root, config, files) {
|
|
711
|
+
const governed = Array.isArray(files) ? files : collectGovernedFiles(root, config);
|
|
712
|
+
const migration = buildRulesMigration({ root, config, files: governed });
|
|
713
|
+
let evidenceLines = [];
|
|
714
|
+
let coverageEvidence = null;
|
|
715
|
+
if (config?.arkRules && Object.keys(config.arkRules).length > 0) {
|
|
716
|
+
try {
|
|
717
|
+
const section = summarizeRulesUnderContract(
|
|
718
|
+
root,
|
|
719
|
+
config,
|
|
720
|
+
{
|
|
721
|
+
files: governed.map((file) => ({
|
|
722
|
+
path: path.relative(root, path.resolve(root, file)).split(path.sep).join('/'),
|
|
723
|
+
})),
|
|
724
|
+
},
|
|
725
|
+
undefined,
|
|
726
|
+
{ rulesMigration: false }
|
|
727
|
+
);
|
|
728
|
+
evidenceLines = formatArkRulesEvidenceLines(section);
|
|
729
|
+
if (section.active === true) {
|
|
730
|
+
coverageEvidence = {
|
|
731
|
+
symbolEvidence: section.symbolEvidence ?? [],
|
|
732
|
+
discarded: section.coverageStats?.discarded ?? null,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
} catch {
|
|
736
|
+
/* inventory still useful when coverage cannot be read */
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
payload: {
|
|
741
|
+
rulesInventory: migration.inventory,
|
|
742
|
+
rulesMigration: migration.rulesMigration,
|
|
743
|
+
nextPilot: migration.nextPilot,
|
|
744
|
+
...(coverageEvidence ? { coverageEvidence } : {}),
|
|
745
|
+
},
|
|
746
|
+
evidenceLines,
|
|
747
|
+
};
|
|
748
|
+
}
|
package/bin/lib/scan-files.mjs
CHANGED
|
@@ -32,6 +32,17 @@ function isInsideRoot(root, target) {
|
|
|
32
32
|
* Internal symlink directories are followed once (TypeScript follows them too),
|
|
33
33
|
* while escaping links fail closed instead of reading arbitrary filesystem paths.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Return `value` flattened. V8 keeps path.join / concatenation results as rope
|
|
37
|
+
* strings (a tree of segment objects); the per-file syscalls of the generic walk
|
|
38
|
+
* branch flattened them as a side effect. Without it, every retained path kept its
|
|
39
|
+
* rope alive and a 20k-file walk peaked ~70 MB higher. ToNumber flattens in place.
|
|
40
|
+
*/
|
|
41
|
+
function flatString(value) {
|
|
42
|
+
void Number(value);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
export function walk(dir, files = [], options = {}) {
|
|
36
47
|
let state = options.state;
|
|
37
48
|
if (!state) {
|
|
@@ -67,6 +78,7 @@ export function walk(dir, files = [], options = {}) {
|
|
|
67
78
|
!state.visitedFiles.has(resolved)
|
|
68
79
|
) {
|
|
69
80
|
state.visitedFiles.add(resolved);
|
|
81
|
+
state.onFile?.(dir, resolved);
|
|
70
82
|
files.push(dir);
|
|
71
83
|
}
|
|
72
84
|
return files;
|
|
@@ -84,6 +96,25 @@ export function walk(dir, files = [], options = {}) {
|
|
|
84
96
|
} else if (entry.isSymbolicLink()) {
|
|
85
97
|
if (isSkippedSourceDir(entry.name)) continue;
|
|
86
98
|
walk(full, files, { state });
|
|
99
|
+
} else if (entry.isFile()) {
|
|
100
|
+
// A regular (non-link) file inside an already-resolved directory: its real
|
|
101
|
+
// path is that directory's real path plus its name. Same result as the
|
|
102
|
+
// per-file lstat + realpath of the generic branch, without two syscalls
|
|
103
|
+
// and two Stats objects for every source file.
|
|
104
|
+
if (!isGovernableSourceFile(entry.name)) continue;
|
|
105
|
+
state.observeInput?.(path.resolve(full), 'lstat');
|
|
106
|
+
state.observeInput?.(path.resolve(full), 'realpath');
|
|
107
|
+
// `resolved` is already a normalized real path and an entry name has no
|
|
108
|
+
// separator, so plain concatenation equals path.join(resolved, name).
|
|
109
|
+
const real = flatString(
|
|
110
|
+
resolved.endsWith(path.sep)
|
|
111
|
+
? `${resolved}${entry.name}`
|
|
112
|
+
: `${resolved}${path.sep}${entry.name}`
|
|
113
|
+
);
|
|
114
|
+
if (state.visitedFiles.has(real)) continue;
|
|
115
|
+
state.visitedFiles.add(real);
|
|
116
|
+
state.onFile?.(full, real);
|
|
117
|
+
files.push(flatString(full));
|
|
87
118
|
} else if (isGovernableSourceFile(entry.name)) {
|
|
88
119
|
walk(full, files, { state });
|
|
89
120
|
}
|
|
@@ -138,6 +169,7 @@ export function collectGovernedFiles(root, config, options = {}) {
|
|
|
138
169
|
visitedDirectories: new Set(),
|
|
139
170
|
visitedFiles: new Set(),
|
|
140
171
|
onDirectory: options.onDirectory,
|
|
172
|
+
onFile: options.onFile,
|
|
141
173
|
observeInput: options.observeInput,
|
|
142
174
|
};
|
|
143
175
|
const raw = (config.include ?? []).flatMap((entry) =>
|
|
@@ -2,16 +2,31 @@
|
|
|
2
2
|
import { layerForRelativePath } from '../ark-layer-match.mjs';
|
|
3
3
|
import { ANALYSIS_COMPLETENESS } from './analysis-completeness.mjs';
|
|
4
4
|
import { evaluateArkRunEditorSensorsFromSource } from './ark-run-sensors.mjs';
|
|
5
|
+
import { evaluateArkOrderEditorSensors } from './ark-order-sensors.mjs';
|
|
6
|
+
import {
|
|
7
|
+
buildArkRuleFileHints,
|
|
8
|
+
evaluateArkRuleSensors,
|
|
9
|
+
extractClassShapesFromSource,
|
|
10
|
+
} from './arkrules-sensors.mjs';
|
|
5
11
|
import { getDiagnosticCatalogEntry } from './diagnostic-catalog.mjs';
|
|
12
|
+
import { demoteExtraPlaneTeethUnderClassificationFloor } from './extra-merge-teeth.mjs';
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
/** Hook-only guidance: a hook deny is already the verdict for that write. */
|
|
15
|
+
export const HOOK_DENY_PREPARE_CHANGE_NOTE = 'Do not call ark_prepare_change from a hook deny.';
|
|
16
|
+
|
|
17
|
+
function surfaceNote(context) {
|
|
18
|
+
return context?.surface === 'hook' ? ` ${HOOK_DENY_PREPARE_CHANGE_NOTE}` : '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lexicalEvidenceIncompleteMessage(file, context) {
|
|
8
22
|
const entry = getDiagnosticCatalogEntry('LEXICAL_EVIDENCE_INCOMPLETE');
|
|
9
23
|
const text = [entry?.why, entry?.fix].filter(Boolean).join(' ');
|
|
10
24
|
return {
|
|
11
25
|
code: 'LEXICAL_EVIDENCE_INCOMPLETE',
|
|
12
26
|
message:
|
|
13
|
-
text ||
|
|
14
|
-
|
|
27
|
+
(text ||
|
|
28
|
+
'This check only saw one file, so it cannot fully prove how the import resolves. The result is provisional — `ark-check` on the project is the authority. For a complete verdict, run `npx arkgate-check --root . --config ark.config.json` (or ark_prepare_change over MCP with the full candidate batch).') +
|
|
29
|
+
surfaceNote(context),
|
|
15
30
|
...(file ? { file } : {}),
|
|
16
31
|
};
|
|
17
32
|
}
|
|
@@ -81,14 +96,132 @@ function arkRunSnippetViolations(source, context = {}) {
|
|
|
81
96
|
}));
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
const TS_CLASS_SHAPE_FILE = /\.(tsx?|mts|cts)$/i;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* File-local ArkRules structure sensors on the proposed source — the same sensors,
|
|
103
|
+
* class-shape extraction, and per-file hints ark-check runs, pinned to this one file.
|
|
104
|
+
* Only enforced (failsStrict) findings block; advisory rules stay CI/doctor warnings.
|
|
105
|
+
*/
|
|
106
|
+
function arkRulesSnippetViolations(source, context = {}) {
|
|
107
|
+
const arkRules = context.arkRules;
|
|
108
|
+
const file = context.relFile;
|
|
109
|
+
if (!arkRules || !(arkRules.structure?.length > 0) || typeof file !== 'string' || !file) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const layers = Array.isArray(context.layers) ? context.layers : [];
|
|
113
|
+
const layerForFile = (pathValue) =>
|
|
114
|
+
pathValue === file && typeof context.layer === 'string'
|
|
115
|
+
? context.layer
|
|
116
|
+
: layerForRelativePath(pathValue, layers);
|
|
117
|
+
let classShapes = [];
|
|
118
|
+
if (TS_CLASS_SHAPE_FILE.test(file)) {
|
|
119
|
+
try {
|
|
120
|
+
classShapes = extractClassShapesFromSource(file, source);
|
|
121
|
+
} catch {
|
|
122
|
+
// Same as the CI resolver: shape extraction never fails the run.
|
|
123
|
+
classShapes = [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const findings = evaluateArkRuleSensors({
|
|
127
|
+
arkRules,
|
|
128
|
+
classShapes,
|
|
129
|
+
files: [file],
|
|
130
|
+
layerForFile,
|
|
131
|
+
fileHints: buildArkRuleFileHints({ [file]: source }),
|
|
132
|
+
});
|
|
133
|
+
// Same classification floor ark-check applies before merge (teeth only on a classified tree).
|
|
134
|
+
return demoteExtraPlaneTeethUnderClassificationFloor(findings, context.classification ?? {})
|
|
135
|
+
.filter((finding) => finding.failsStrict)
|
|
136
|
+
.map((finding) => ({
|
|
137
|
+
...finding,
|
|
138
|
+
nextAction:
|
|
139
|
+
getDiagnosticCatalogEntry('ARKRULE_STRUCTURE')?.fix ??
|
|
140
|
+
'Fix the ArkRules structure finding in this file, then retry the write.',
|
|
141
|
+
failsStrict: true,
|
|
142
|
+
severity: 'error',
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** ArkOrder editor sensors (generic update, kernel-in-domain, ξ writes) on one source. */
|
|
147
|
+
function arkOrderSnippetViolations(source, context = {}) {
|
|
148
|
+
const arkOrder = context.arkOrder;
|
|
149
|
+
const file = context.relFile;
|
|
150
|
+
if (!arkOrder || typeof file !== 'string' || !file) return [];
|
|
151
|
+
const layers = Array.isArray(context.layers) ? context.layers : [];
|
|
152
|
+
const fromLayer =
|
|
153
|
+
typeof context.layer === 'string' ? context.layer : layerForRelativePath(file, layers);
|
|
154
|
+
const layerMeta = layers.find((layer) => layer?.name === fromLayer);
|
|
155
|
+
return evaluateArkOrderEditorSensors({
|
|
156
|
+
arkOrder,
|
|
157
|
+
file,
|
|
158
|
+
source,
|
|
159
|
+
fromLayer,
|
|
160
|
+
intentPrefixes: layerMeta?.intentPrefixes ?? [],
|
|
161
|
+
...(context.classification ? { classification: context.classification } : {}),
|
|
162
|
+
})
|
|
163
|
+
.filter((finding) => finding.failsStrict)
|
|
164
|
+
.map((finding) => ({
|
|
165
|
+
ruleId: finding.ruleId,
|
|
166
|
+
code: finding.ruleId,
|
|
167
|
+
message: finding.message,
|
|
168
|
+
file: finding.file,
|
|
169
|
+
line: finding.line,
|
|
170
|
+
...(finding.fromLayer ? { fromLayer: finding.fromLayer } : {}),
|
|
171
|
+
...(finding.target ? { target: finding.target } : {}),
|
|
172
|
+
nextAction: finding.nextAction,
|
|
173
|
+
failsStrict: true,
|
|
174
|
+
severity: 'error',
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The write gate could not load part of its own contract (e.g. a referenced ArkRules
|
|
180
|
+
* file is missing). CI fails closed on the same input, so the write path does too.
|
|
181
|
+
*/
|
|
182
|
+
function contractLoadViolations(context = {}) {
|
|
183
|
+
const errors = Array.isArray(context.contractErrors) ? context.contractErrors : [];
|
|
184
|
+
return errors.map((error) => ({
|
|
185
|
+
ruleId: 'WRITE_GATE_UNAVAILABLE',
|
|
186
|
+
code: 'WRITE_GATE_UNAVAILABLE',
|
|
187
|
+
message: `Invalid Effective Contract: ${
|
|
188
|
+
typeof error === 'string' ? error : `${error?.path ?? ''}: ${error?.message ?? ''}`.trim()
|
|
189
|
+
}`,
|
|
190
|
+
...(context.relFile ? { file: context.relFile } : {}),
|
|
191
|
+
nextAction:
|
|
192
|
+
getDiagnosticCatalogEntry('WRITE_GATE_UNAVAILABLE')?.fix ??
|
|
193
|
+
'Fix the ArkRules reference in ark.config.json, then retry the write.',
|
|
194
|
+
failsStrict: true,
|
|
195
|
+
severity: 'error',
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extras (ArkRules structure + ArkOrder) run only on files CI would scan. A file
|
|
201
|
+
* outside include / excluded is never judged by these planes in CI either.
|
|
202
|
+
*/
|
|
203
|
+
function extraPlaneSnippetViolations(source, context = {}) {
|
|
204
|
+
if (context.inScope === false) return [];
|
|
205
|
+
return [
|
|
206
|
+
...contractLoadViolations(context),
|
|
207
|
+
...arkRulesSnippetViolations(source, context),
|
|
208
|
+
...arkOrderSnippetViolations(source, context),
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
84
212
|
export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
85
213
|
const observed = gate.validate(source, context);
|
|
86
214
|
const arkRunViolations = arkRunSnippetViolations(source, context);
|
|
215
|
+
const extraViolations = extraPlaneSnippetViolations(source, context);
|
|
87
216
|
const base = {
|
|
88
|
-
valid:
|
|
217
|
+
valid:
|
|
218
|
+
Boolean(observed.lexicalValid ?? observed.valid) &&
|
|
219
|
+
arkRunViolations.length === 0 &&
|
|
220
|
+
extraViolations.length === 0,
|
|
89
221
|
violations: [
|
|
90
222
|
...(Array.isArray(observed.violations) ? observed.violations : []),
|
|
91
223
|
...arkRunViolations,
|
|
224
|
+
...extraViolations,
|
|
92
225
|
],
|
|
93
226
|
};
|
|
94
227
|
const file = context.filePath;
|
|
@@ -154,7 +287,8 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
|
154
287
|
'ANALYSIS_PARSE_INCOMPLETE',
|
|
155
288
|
`Analysis partial: ${detail}`,
|
|
156
289
|
file,
|
|
157
|
-
'Incremental mid-edit parse errors are normal. Finish the source, then re-run `npx arkgate-check` (or the write hook).
|
|
290
|
+
'Incremental mid-edit parse errors are normal. Finish the source, then re-run `npx arkgate-check` (or the write hook).' +
|
|
291
|
+
surfaceNote(context)
|
|
158
292
|
),
|
|
159
293
|
line: first?.line ?? 1,
|
|
160
294
|
column: first?.column ?? 1,
|
|
@@ -169,7 +303,7 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
|
169
303
|
valid: false,
|
|
170
304
|
lexicalValid: base.valid,
|
|
171
305
|
completeness: ANALYSIS_COMPLETENESS.partial,
|
|
172
|
-
completenessReasons: [lexicalEvidenceIncompleteMessage(file)],
|
|
306
|
+
completenessReasons: [lexicalEvidenceIncompleteMessage(file, context)],
|
|
173
307
|
};
|
|
174
308
|
} catch {
|
|
175
309
|
return {
|
|
@@ -25,6 +25,40 @@ export const DEFAULT_INTENT_PREFIXES = Object.freeze([
|
|
|
25
25
|
{ layer: 'SecurityAuditObservability', prefixes: ['Security.', 'Audit.', 'Observability.'] },
|
|
26
26
|
{ layer: 'Kernel', prefixes: ['Kernel.'] },
|
|
27
27
|
]);
|
|
28
|
+
/**
|
|
29
|
+
* Runtime intent prefixes for one config layer: its declared `intentPrefixes`,
|
|
30
|
+
* else the built-in prefixes when the layer uses a canonical 11-layer name
|
|
31
|
+
* (`DomainModel` → `Domain.`, …; the same table the static gate falls back to),
|
|
32
|
+
* else none. Keeps `ark init` configs, which name canonical layers without
|
|
33
|
+
* declaring prefixes, resolvable by the ArkRun kernel.
|
|
34
|
+
*/
|
|
35
|
+
export function effectiveIntentPrefixes(layer) {
|
|
36
|
+
const declared = (layer.intentPrefixes ?? []).filter((prefix) => prefix.trim().length > 0);
|
|
37
|
+
if (declared.length > 0)
|
|
38
|
+
return declared;
|
|
39
|
+
return DEFAULT_INTENT_PREFIXES.find((entry) => entry.layer === layer.name)?.prefixes ?? [];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Layers named by an `allowed: false` (non-peerIsolation) rule that no intent can map to at runtime
|
|
43
|
+
* (no declared `intentPrefixes` and not a canonical layer name). Hard observed
|
|
44
|
+
* layer flow can never fire for such a rule. Sorted, unique.
|
|
45
|
+
*/
|
|
46
|
+
export function unresolvableLayerFlowLayers(config) {
|
|
47
|
+
const resolvable = new Set(config.layers
|
|
48
|
+
.filter((layer) => effectiveIntentPrefixes(layer).length > 0)
|
|
49
|
+
.map((layer) => layer.name));
|
|
50
|
+
const missing = new Set();
|
|
51
|
+
for (const rule of config.rules ?? []) {
|
|
52
|
+
// peerIsolation walls are file-path slice rules; runtime flow never evaluates them.
|
|
53
|
+
if (rule.allowed !== false || rule.peerIsolation === true)
|
|
54
|
+
continue;
|
|
55
|
+
for (const name of [rule.from, rule.to]) {
|
|
56
|
+
if (!resolvable.has(name))
|
|
57
|
+
missing.add(name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [...missing].sort();
|
|
61
|
+
}
|
|
28
62
|
/** Longest matching prefix wins; declaration order resolves an identical-prefix tie. */
|
|
29
63
|
export function resolveIntentLayer(intent, layers) {
|
|
30
64
|
const candidates = layers.flatMap((layer, layerIndex) => (layer.prefixes ?? layer.intentPrefixes ?? []).map((prefix) => ({
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/stableHash.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/stable-hash.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Stable FNV-1a hash. Identity/fingerprint only — not a security primitive. */
|
|
12
|
+
export function deterministicHash(value) {
|
|
13
|
+
let hash = 0x811c9dc5;
|
|
14
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
15
|
+
hash ^= value.charCodeAt(index);
|
|
16
|
+
hash = Math.imul(hash, 0x01000193);
|
|
17
|
+
}
|
|
18
|
+
return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
19
|
+
}
|
|
20
|
+
/** Serialize JSON-like values with sorted object keys for reproducible hashes. */
|
|
21
|
+
export function stableSerialize(value) {
|
|
22
|
+
if (value === null || typeof value !== 'object')
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return `[${value.map(stableSerialize).join(',')}]`;
|
|
26
|
+
const object = value;
|
|
27
|
+
return `{${Object.keys(object)
|
|
28
|
+
.sort()
|
|
29
|
+
.map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`)
|
|
30
|
+
.join(',')}}`;
|
|
31
|
+
}
|
|
32
|
+
function feedText(text, state) {
|
|
33
|
+
let hash = state.hash;
|
|
34
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
35
|
+
hash ^= text.charCodeAt(index);
|
|
36
|
+
hash = Math.imul(hash, 0x01000193);
|
|
37
|
+
}
|
|
38
|
+
state.hash = hash;
|
|
39
|
+
}
|
|
40
|
+
function feedStable(value, state) {
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
feedText('[', state);
|
|
43
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
44
|
+
if (index > 0)
|
|
45
|
+
feedText(',', state);
|
|
46
|
+
// `map` skips holes and `join` renders an undefined element as ''.
|
|
47
|
+
if (!(index in value))
|
|
48
|
+
continue;
|
|
49
|
+
const item = value[index];
|
|
50
|
+
if (item !== null && typeof item === 'object')
|
|
51
|
+
feedStable(item, state);
|
|
52
|
+
else {
|
|
53
|
+
const text = JSON.stringify(item);
|
|
54
|
+
if (text !== undefined)
|
|
55
|
+
feedText(text, state);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
feedText(']', state);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const object = value;
|
|
62
|
+
const keys = Object.keys(object).sort();
|
|
63
|
+
feedText('{', state);
|
|
64
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
65
|
+
if (index > 0)
|
|
66
|
+
feedText(',', state);
|
|
67
|
+
const key = keys[index];
|
|
68
|
+
feedText(`${JSON.stringify(key)}:`, state);
|
|
69
|
+
const item = object[key];
|
|
70
|
+
// A template literal renders an undefined serialization as 'undefined'.
|
|
71
|
+
if (item !== null && typeof item === 'object')
|
|
72
|
+
feedStable(item, state);
|
|
73
|
+
else
|
|
74
|
+
feedText(`${JSON.stringify(item)}`, state);
|
|
75
|
+
}
|
|
76
|
+
feedText('}', state);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* `deterministicHash(stableSerialize(value))`, byte for byte, without building
|
|
80
|
+
* the serialized text. A large fact set hashes without first materializing a
|
|
81
|
+
* multi-megabyte string and every intermediate join.
|
|
82
|
+
*/
|
|
83
|
+
export function stableHash(value) {
|
|
84
|
+
if (value === null || typeof value !== 'object') {
|
|
85
|
+
return deterministicHash(stableSerialize(value));
|
|
86
|
+
}
|
|
87
|
+
const state = { hash: 0x811c9dc5 };
|
|
88
|
+
feedStable(value, state);
|
|
89
|
+
return `fnv1a-${(state.hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
90
|
+
}
|