@githolon/dsl 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ // COPY THIS INTO YOUR FLUTTER APP. It is an EXAMPLE, not part of the `nomos_client` library: it depends on
2
+ // `flutter_secure_storage`, which the pure-Dart `nomos_client` package deliberately does NOT depend on (so
3
+ // the protocol logic stays Flutter-free and unit-testable). This is the ~40 lines of app-specific glue for
4
+ // where your device secret lives — the E2E key CUSTODY policy is yours; the protocol plumbing is not.
5
+ //
6
+ // THE SEALING CLIENT (e2e_sealing_client.md): a generated `<Domain>Client(bridge, keyStore: ...)` seals
7
+ // encrypted directive fields before dispatch + decrypts encrypted read fields automatically. The `keyStore`
8
+ // is the ONE thing you supply. `WrappedKeyStore` (in `nomos_client`) is the default: it unwraps this
9
+ // principal's on-chain WrappedKeys with a DEVICE SECRET. This example shows where that device secret lives —
10
+ // the platform keychain / Keystore via `flutter_secure_storage` — so it survives app restarts, stays off the
11
+ // chain, and never leaves the device.
12
+ //
13
+ // WIRING (once per signed-in principal):
14
+ // final store = await SecureDeviceKeyStore.open(bridge: bridge, principal: myUid);
15
+ // final app = NotesClient(bridge, actor: myUid, keyStore: store); // reads decrypt, writes seal
16
+ // final rows = await app.notesByOwner(owner: myUid); // `body` comes back plaintext
17
+ //
18
+ // The OWNER ceremony (grantAndWrap / revokeAndRotate / rotate) uses the SAME device pubkey this store
19
+ // publishes — see `<Domain>Client(bridge, actor: owner).encryption`.
20
+
21
+ import 'dart:convert';
22
+
23
+ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
24
+ import 'package:nomos_client/nomos_client.dart';
25
+
26
+ /// A [NomosKeyStore] whose device secret is held in the platform keychain (iOS Keychain / Android Keystore)
27
+ /// via `flutter_secure_storage`. It delegates the scope-key unwrap to the framework's [WrappedKeyStore]; the
28
+ /// only thing it adds is DURABLE, PLATFORM-PROTECTED custody of the X25519 device secret.
29
+ class SecureDeviceKeyStore implements NomosKeyStore {
30
+ final WrappedKeyStore _inner;
31
+
32
+ /// This device's X25519 PUBLIC key — publish it (publishEncKey) / hand it to the owner as `publicKey` on
33
+ /// `grantAndWrap` so the scope key can be wrapped TO this device.
34
+ final String devicePublicKey;
35
+
36
+ SecureDeviceKeyStore._(this._inner, this.devicePublicKey);
37
+
38
+ /// Open (or first-time provision) the store for `principal`. On first run it MINTS an X25519 device keypair
39
+ /// in the kernel, persists the SECRET in secure storage, and returns the store; on later runs it reuses it.
40
+ static Future<SecureDeviceKeyStore> open({
41
+ required NomosBridge bridge,
42
+ required String principal,
43
+ FlutterSecureStorage storage = const FlutterSecureStorage(),
44
+ }) async {
45
+ final secretKey = 'nomos.enc.deviceSecret.$principal';
46
+ final publicKey = 'nomos.enc.devicePublic.$principal';
47
+ var secret = await storage.read(key: secretKey);
48
+ var public = await storage.read(key: publicKey);
49
+ if (secret == null || public == null) {
50
+ // FIRST RUN: mint the device keypair (secret stays client-side, never on chain) and persist it.
51
+ final kp = await bridge.cryptoKeygen();
52
+ secret = (kp['secret'] ?? '').toString();
53
+ public = (kp['public'] ?? '').toString();
54
+ await storage.write(key: secretKey, value: secret);
55
+ await storage.write(key: publicKey, value: public);
56
+ }
57
+ final inner = WrappedKeyStore(
58
+ bridge: bridge, principal: principal, deviceSecret: secret);
59
+ return SecureDeviceKeyStore._(inner, public);
60
+ }
61
+
62
+ @override
63
+ Future<NomosScopeKey?> scopeKey(String scope) => _inner.scopeKey(scope);
64
+
65
+ @override
66
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes) =>
67
+ _inner.readKeys(scopes);
68
+
69
+ /// Convenience: the JSON a `publishEncKey` payload carries (bind the device pubkey to this principal on
70
+ /// chain so an owner's `grantAndWrap` can resolve it without you passing `publicKey` each time).
71
+ Map<String, Object?> publishPayload({required String publishedAt}) => {
72
+ 'principal': _inner.principal,
73
+ 'encPublicKey': devicePublicKey,
74
+ 'publishedAt': publishedAt,
75
+ };
76
+
77
+ @override
78
+ String toString() => 'SecureDeviceKeyStore(${jsonEncode({
79
+ 'principal': _inner.principal,
80
+ 'devicePublicKey': devicePublicKey,
81
+ })})';
82
+ }
@@ -15,6 +15,7 @@
15
15
  library;
16
16
 
17
17
  import 'dart:async';
18
+ import 'dart:convert';
18
19
  import 'dart:math';
19
20
 
20
21
  import 'errors.dart' show nomosErrorFromInfo;
@@ -382,6 +383,287 @@ class WrappedKeyStore implements NomosKeyStore {
382
383
  }
383
384
  }
384
385
 
386
+ // ── E2E OWNER CEREMONY (the sealing client, slice 2) — the owner-side twin of [WrappedKeyStore]. Domain-
387
+ // agnostic, so it lives here (re-exported per client) exactly like the KeyStore. It composes the framework
388
+ // key-epoch fan-out (`advanceKeyEpoch`, dispatched by its REAL id) with the domain's own grant/revoke
389
+ // directive, resolving the roster + each member's wrap-target pubkey FROM THE CHAIN — so the operator-blind
390
+ // handshake (grant → advance epoch → wrap to every member) is ONE call. Mirror of the TS `_ownerEncryption`. ──
391
+
392
+ /// Thrown by the owner ceremony when a roster member has NO wrap-target key (no publishEncKey on chain, no
393
+ /// prior wrap, no supplied override) — the ceremony NEVER wraps to a default (fail-closed; a keyless member
394
+ /// is "not yet a member", per e2e_encryption.md's FOOTGUNS).
395
+ class EncryptionMemberKeyMissing implements Exception {
396
+ final String principal;
397
+ final String scope;
398
+ String get code => 'nomos.crypto.member_key_missing';
399
+ String get recommendedAction => 'publishEncKey';
400
+ EncryptionMemberKeyMissing(this.principal, this.scope);
401
+ @override
402
+ String toString() =>
403
+ 'EncryptionMemberKeyMissing: cannot wrap the "$scope" scope key to member "$principal" — no encryption public key on chain (publishEncKey) and none supplied (fail-closed)';
404
+ }
405
+
406
+ /// Thrown when the resolved roster != the caller's declared `expectRoster` BEFORE dispatch — pre-firing the
407
+ /// kernel fan-out gate (wrap set == roster) so a rotation against a stale roster view never spends an epoch.
408
+ class EncryptionRosterMismatch implements Exception {
409
+ final String scope;
410
+ final List<String> missing;
411
+ final List<String> extra;
412
+ String get code => 'nomos.crypto.roster_mismatch';
413
+ String get recommendedAction => 'reconcileRoster';
414
+ EncryptionRosterMismatch(this.scope, this.missing, this.extra);
415
+ @override
416
+ String toString() =>
417
+ 'EncryptionRosterMismatch: roster for "$scope" != expected (the fan-out gate would refuse): missing $missing, unexpected $extra';
418
+ }
419
+
420
+ /// The outcome of an epoch-advancing ceremony verb: the new local [head], the [epoch] opened, and the
421
+ /// [roster] the key was wrapped to.
422
+ class NomosEpochResult {
423
+ final String head;
424
+ final int epoch;
425
+ final List<String> roster;
426
+ const NomosEpochResult(
427
+ {required this.head, required this.epoch, required this.roster});
428
+ }
429
+
430
+ /// THE OWNER CEREMONY surface — `<Domain>Client(bridge, actor: owner).encryption`. All three verbs open a
431
+ /// FRESH epoch (a fresh 32-byte secret) and fan the wraps to the resolved roster; the membership change (if
432
+ /// any) is the domain grant/revoke dispatched first, in the same call.
433
+ class NomosEncryption {
434
+ final NomosBridge bridge;
435
+ final String domain;
436
+ final String? domainHash;
437
+ final String? actor;
438
+
439
+ /// The encryption scopes this domain defines (each a `requires(cap)`).
440
+ final List<String> scopes;
441
+
442
+ NomosEncryption(
443
+ {required this.bridge,
444
+ required this.domain,
445
+ this.domainHash,
446
+ this.actor,
447
+ required this.scopes});
448
+
449
+ String _defScope([String? scope]) {
450
+ if (scope != null && scope.isNotEmpty) return scope;
451
+ if (scopes.length == 1) return scopes.first;
452
+ throw StateError(
453
+ 'this domain defines ${scopes.length} encryption scopes $scopes — pass a scope to pick one');
454
+ }
455
+
456
+ // A BARE scope IS `workspace:self#<relation>` (the era rule); a per-record scope is `<type>:<id>#<relation>`.
457
+ ({String object, String relation}) _parseScope(String scope) {
458
+ final h = scope.indexOf('#');
459
+ return h < 0
460
+ ? (object: 'workspace:self', relation: scope)
461
+ : (object: scope.substring(0, h), relation: scope.substring(h + 1));
462
+ }
463
+
464
+ /// The CURRENT roster of a scope (active direct-grant members, principal-form). Defaults to the sole scope.
465
+ Future<List<String>> roster([String? scope]) => _readRoster(_defScope(scope));
466
+
467
+ Future<List<String>> _readRoster(String scope) async {
468
+ final s = _parseScope(scope);
469
+ final rows =
470
+ await bridge.queryById('reltuple-${s.object}-${s.relation}');
471
+ final subjects = rows.isEmpty
472
+ ? const <String, dynamic>{}
473
+ : ((rows.first['data'] as Map?)?['subjects'] as Map?) ??
474
+ const <String, dynamic>{};
475
+ final out = <String>[];
476
+ subjects.forEach((subj, leafRaw) {
477
+ Map? leaf;
478
+ try {
479
+ leaf = leafRaw is String
480
+ ? jsonDecode(leafRaw) as Map
481
+ : (leafRaw as Map);
482
+ } catch (_) {
483
+ leaf = null;
484
+ }
485
+ final status = leaf?['status'];
486
+ if (status != null && status != 'active') return;
487
+ final p = subj.toString();
488
+ out.add(p.startsWith('user:') ? p.substring(5) : p);
489
+ });
490
+ out.sort();
491
+ return out;
492
+ }
493
+
494
+ Future<int> _currentEpoch(String scope) async {
495
+ final rows = await bridge.queryById('keyscope-$scope');
496
+ if (rows.isEmpty) return -1;
497
+ final e = (rows.first['data'] as Map?)?['currentEpoch'];
498
+ return e is num ? e.toInt() : -1;
499
+ }
500
+
501
+ Future<String> _freshScopeKey() async {
502
+ final kp = await bridge.cryptoKeygen();
503
+ final secret = base64Decode((kp['secret'] ?? '').toString());
504
+ return base64Encode(secret.sublist(0, 32)); // the 32-byte epoch secret; NEVER lands on chain
505
+ }
506
+
507
+ Future<String?> _memberPubKey(String principal,
508
+ {String? newcomer, String? newcomerPub, Map<String, String>? memberKeys}) async {
509
+ if (memberKeys != null && memberKeys[principal] != null) {
510
+ return memberKeys[principal];
511
+ }
512
+ if (newcomer == principal && newcomerPub != null) return newcomerPub;
513
+ // Published enc key (encKeyByPrincipal) — present only when the domain composed the registry; tolerate absence.
514
+ try {
515
+ final reg =
516
+ await bridge.query('encKeyByPrincipal', {'principal': principal});
517
+ if (reg.isNotEmpty) {
518
+ final pub = (reg.first['data'] as Map?)?['encPublicKey'];
519
+ if (pub != null && pub.toString().isNotEmpty) return pub.toString();
520
+ }
521
+ } catch (_) {/* not routed — fall through to the prior-wrap lane */}
522
+ // The member's most-recent WrappedKey pins the pubkey it was last sealed to (rotation needs no re-publish).
523
+ try {
524
+ final wraps = await bridge
525
+ .query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
526
+ int bestEpoch = -1;
527
+ String? bestPub;
528
+ for (final w in wraps) {
529
+ final d = (w['data'] as Map?) ?? const {};
530
+ final pub = d['recipientPubKey'];
531
+ final ep = (d['epoch'] as num?)?.toInt() ?? 0;
532
+ if (pub != null && ep >= bestEpoch) {
533
+ bestEpoch = ep;
534
+ bestPub = pub.toString();
535
+ }
536
+ }
537
+ if (bestPub != null) return bestPub;
538
+ } catch (_) {/* not routed */}
539
+ return null;
540
+ }
541
+
542
+ Future<NomosEpochResult> _advance(
543
+ String scope, {
544
+ String? addPrincipal,
545
+ String? addPublicKey,
546
+ List<String> remove = const [],
547
+ String? scopeKey,
548
+ Map<String, String>? memberKeys,
549
+ List<String>? expectRoster,
550
+ String? openedAt,
551
+ }) async {
552
+ final roster = (await _readRoster(scope)).toSet();
553
+ if (addPrincipal != null) roster.add(addPrincipal);
554
+ roster.removeAll(remove);
555
+ final members = roster.toList()..sort();
556
+ // PRE-FIRE the fan-out gate: if the caller declared the roster it means to wrap, refuse on drift.
557
+ if (expectRoster != null) {
558
+ final want = expectRoster.toSet();
559
+ final missing = (want.difference(roster).toList())..sort();
560
+ final extra = members.where((m) => !want.contains(m)).toList();
561
+ if (missing.isNotEmpty || extra.isNotEmpty) {
562
+ throw EncryptionRosterMismatch(scope, missing, extra);
563
+ }
564
+ }
565
+ final key = scopeKey ?? await _freshScopeKey();
566
+ final wraps = <Map<String, Object?>>[];
567
+ for (final principal in members) {
568
+ final pub = await _memberPubKey(principal,
569
+ newcomer: addPrincipal, newcomerPub: addPublicKey, memberKeys: memberKeys);
570
+ if (pub == null) throw EncryptionMemberKeyMissing(principal, scope);
571
+ final w = await bridge.cryptoWrapKey(recipientPub: pub, scopeKey: key);
572
+ wraps.add({
573
+ 'recipientPrincipal': principal,
574
+ 'recipientPubKey': pub,
575
+ 'hpkeEpk': (w['hpkeEpk'] ?? '').toString(),
576
+ 'ct': (w['ct'] ?? '').toString(),
577
+ });
578
+ }
579
+ final epoch = (await _currentEpoch(scope)) + 1; // forward-only: strictly increments
580
+ final head = await bridge.offerDirective(
581
+ domain: domain,
582
+ directiveId: 'advanceKeyEpoch',
583
+ payload: {
584
+ 'scope': scope,
585
+ 'epoch': epoch,
586
+ 'openedAt': openedAt ?? DateTime.now().toUtc().toIso8601String(),
587
+ 'wraps': wraps,
588
+ },
589
+ actor: actor,
590
+ domainHash: domainHash,
591
+ );
592
+ return NomosEpochResult(head: head, epoch: epoch, roster: members);
593
+ }
594
+
595
+ /// Grant a newcomer + open a fresh epoch wrapping the key to EVERY member (incl. the newcomer). Dispatches
596
+ /// `grantDirective(grantPayload)` first, then `advanceKeyEpoch`. `publicKey` is the newcomer's X25519 key
597
+ /// (else resolved from an on-chain publishEncKey). Existing members' keys come from their prior wraps.
598
+ Future<NomosEpochResult> grantAndWrap({
599
+ required String principal,
600
+ String? publicKey,
601
+ String? scope,
602
+ required String grantDirective,
603
+ required Map<String, Object?> grantPayload,
604
+ String? scopeKey,
605
+ Map<String, String>? memberKeys,
606
+ List<String>? expectRoster,
607
+ String? openedAt,
608
+ }) async {
609
+ final s = _defScope(scope);
610
+ await bridge.offerDirective(
611
+ domain: domain,
612
+ directiveId: grantDirective,
613
+ payload: grantPayload,
614
+ actor: actor,
615
+ domainHash: domainHash);
616
+ return _advance(s,
617
+ addPrincipal: principal,
618
+ addPublicKey: publicKey,
619
+ scopeKey: scopeKey,
620
+ memberKeys: memberKeys,
621
+ expectRoster: expectRoster,
622
+ openedAt: openedAt);
623
+ }
624
+
625
+ /// Revoke a member + open a fresh epoch re-wrapping the key to the SURVIVORS only. FORWARD-ONLY: the removed
626
+ /// member gets no NEW reads, but old ciphertext it already holds a key for stays readable (not re-encrypted).
627
+ Future<NomosEpochResult> revokeAndRotate({
628
+ required String principal,
629
+ String? scope,
630
+ required String revokeDirective,
631
+ required Map<String, Object?> revokePayload,
632
+ String? scopeKey,
633
+ Map<String, String>? memberKeys,
634
+ List<String>? expectRoster,
635
+ String? openedAt,
636
+ }) async {
637
+ final s = _defScope(scope);
638
+ await bridge.offerDirective(
639
+ domain: domain,
640
+ directiveId: revokeDirective,
641
+ payload: revokePayload,
642
+ actor: actor,
643
+ domainHash: domainHash);
644
+ return _advance(s,
645
+ remove: [principal],
646
+ scopeKey: scopeKey,
647
+ memberKeys: memberKeys,
648
+ expectRoster: expectRoster,
649
+ openedAt: openedAt);
650
+ }
651
+
652
+ /// Routine rotation — open a fresh epoch re-wrapping the key to the CURRENT roster (no membership change).
653
+ Future<NomosEpochResult> rotate({
654
+ String? scope,
655
+ String? scopeKey,
656
+ Map<String, String>? memberKeys,
657
+ List<String>? expectRoster,
658
+ String? openedAt,
659
+ }) =>
660
+ _advance(_defScope(scope),
661
+ scopeKey: scopeKey,
662
+ memberKeys: memberKeys,
663
+ expectRoster: expectRoster,
664
+ openedAt: openedAt);
665
+ }
666
+
385
667
  /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
386
668
  class NomosOfferOutcome {
387
669
  final String head;
package/lsp_server.mjs ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
3
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
4
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
5
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
6
+
7
+ // `githolon lsp` launcher — the twin of `check_package.mjs`: resolve `tsx` (the caller's, else the
8
+ // DSL's own dependency) and run `src/lsp_main.ts` under it. The server imports the tenant's TS domain
9
+ // modules IN-PROCESS on every re-check (needs the tsx ESM loader). stdio is inherited so the LSP wire
10
+ // (stdin/stdout, JSON-RPC) passes through untouched; the editor spawns this and talks to it directly.
11
+ import { spawn } from "node:child_process";
12
+ import { readFileSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import path from "node:path";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+
17
+ const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
18
+ const MAIN = path.join(DSL_DIR, "src", "lsp_main.ts");
19
+
20
+ function resolvePkgDir(name, fromDir) {
21
+ try {
22
+ const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
23
+ return path.dirname(req.resolve(`${name}/package.json`));
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
30
+ if (!tsxDir) {
31
+ console.error("githolon lsp: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
32
+ process.exit(1);
33
+ }
34
+ const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
35
+ const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
36
+ const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
37
+
38
+ // spawn (not execFileSync) so the long-lived server's stdio streams stay connected for the session.
39
+ const child = spawn(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
40
+ stdio: "inherit",
41
+ cwd: process.cwd(),
42
+ });
43
+ child.on("exit", (code) => process.exit(code ?? 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -47,7 +47,8 @@
47
47
  "!dart/.dart_tool",
48
48
  "!dart/build",
49
49
  "compile_package.mjs",
50
- "check_package.mjs"
50
+ "check_package.mjs",
51
+ "lsp_server.mjs"
51
52
  ],
52
53
  "publishConfig": {
53
54
  "access": "public"
@@ -59,6 +60,8 @@
59
60
  "dependencies": {
60
61
  "esbuild": "0.24.2",
61
62
  "tsx": "^4.19.2",
63
+ "vscode-languageserver": "^9.0.1",
64
+ "vscode-languageserver-textdocument": "^1.0.12",
62
65
  "zod": "^4.4.3"
63
66
  },
64
67
  "devDependencies": {
@@ -10,13 +10,22 @@
10
10
  * discovery), runs {@link checkDomainModules}, prints the unified report, and exits
11
11
  * non-zero on any REFUSE-level finding. `compile` runs the same twins implicitly.
12
12
  */
13
- import { existsSync } from "node:fs";
13
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
14
14
  import path from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
 
17
17
  import { composeDomainModule, type Mod } from "./build_package.js";
18
18
  import type { DomainModule } from "./codegen_dart.js";
19
- import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
19
+ import { checkDomainModules, formatFindings, hasRefusal, type Finding } from "./law_check.js";
20
+ import { LawSourceMap } from "./law_source_map.js";
21
+ import {
22
+ currentEvolveLaw,
23
+ evolveDiff,
24
+ evolveLawFromDeployJson,
25
+ evolveLawFromLockfile,
26
+ type EvolveLaw,
27
+ type StableIdLockfile,
28
+ } from "./evolve_preview.js";
20
29
 
21
30
  interface DomainConfig {
22
31
  key: string;
@@ -40,35 +49,169 @@ function fail(msg: string): never {
40
49
  * applies the secondary-ensure auto-declare). Returns the composed modules ready for the twins.
41
50
  */
42
51
  export async function loadComposedModules(configArg: string): Promise<DomainModule[]> {
52
+ return (await loadComposedModulesWithSources(configArg)).modules;
53
+ }
54
+
55
+ /**
56
+ * Like {@link loadComposedModules}, but ALSO returns the domain-key → ORDERED module file paths map
57
+ * (the source map's input — the composed DomainModule does not retain source paths, so the check/LSP
58
+ * lane threads them through). The config is the one place that names both the domain key and its files.
59
+ */
60
+ export async function loadComposedModulesWithSources(
61
+ configArg: string,
62
+ ): Promise<{ modules: DomainModule[]; domainFiles: Map<string, string[]> }> {
43
63
  const configPath = path.resolve(process.cwd(), configArg);
44
64
  if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
45
65
  const cfgDir = path.dirname(configPath);
46
- const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
66
+ // Cache-bust the config import so a re-load (the LSP re-check) sees edits, not the first-import cache.
67
+ const cfgUrl = `${pathToFileURL(configPath).href}?t=${Date.now()}`;
68
+ const cfgModule = (await import(cfgUrl)) as { default?: PackageConfig };
47
69
  const cfg = cfgModule.default;
48
70
  if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
49
71
  fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
50
72
  }
51
73
  const composed: DomainModule[] = [];
74
+ const domainFiles = new Map<string, string[]>();
52
75
  for (const d of cfg.domains) {
53
76
  if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
54
77
  fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
55
78
  }
56
79
  const mods: Mod[] = [];
80
+ const files: string[] = [];
57
81
  for (const rel of d.modules) {
58
82
  const abs = path.resolve(cfgDir, rel);
59
83
  if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
60
- mods.push((await import(pathToFileURL(abs).href)) as Mod);
84
+ files.push(abs);
85
+ // Cache-bust so a re-import (the LSP re-check after an edit) re-evaluates the edited module.
86
+ mods.push((await import(`${pathToFileURL(abs).href}?t=${Date.now()}`)) as Mod);
61
87
  }
88
+ domainFiles.set(d.key, files);
62
89
  composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
63
90
  }
64
- return composed;
91
+ return { modules: composed, domainFiles };
92
+ }
93
+
94
+ /**
95
+ * Load the PRIOR law surface for `--against <path>` (the evolve preview's baseline). The supported
96
+ * prior is a DEPLOY.JSON (`build/<name>.deploy.json`), a BUILD DIR / package dir containing one, or
97
+ * the committed `nomos.stable-ids.json` LOCKFILE (aggregate identity only — no directive payloads).
98
+ * A live `https://…` prior is NOT supported (the installed package's stable-ids aren't retrievable
99
+ * from GET /domains); point `--against` at the deployed law's committed deploy.json instead.
100
+ */
101
+ function loadPriorEvolveLaw(arg: string): { law: EvolveLaw; source: string; note?: string } {
102
+ if (/^https?:\/\//.test(arg)) {
103
+ fail(
104
+ `--against does not support a live URL (${arg}) — the installed package's #58 stable-ids are ` +
105
+ `not retrievable from GET /domains. Point --against at the deployed law's build/<name>.deploy.json.`,
106
+ );
107
+ }
108
+ const abs = path.resolve(process.cwd(), arg);
109
+ if (!existsSync(abs)) fail(`--against: prior not found: ${abs}`);
110
+ // Resolve a directory to a deploy.json (build dir or package dir) or its lockfile.
111
+ let file = abs;
112
+ if (statSync(abs).isDirectory()) {
113
+ const deploy = findDeployJson(abs);
114
+ const lock = path.join(abs, "nomos.stable-ids.json");
115
+ if (deploy !== undefined) file = deploy;
116
+ else if (existsSync(lock)) file = lock;
117
+ else fail(`--against: no *.deploy.json or nomos.stable-ids.json under ${abs}`);
118
+ }
119
+ let parsed: unknown;
120
+ try {
121
+ parsed = JSON.parse(readFileSync(file, "utf8"));
122
+ } catch (e) {
123
+ fail(`--against: could not parse ${file}: ${(e as Error).message}`);
124
+ }
125
+ const p = parsed as Record<string, unknown>;
126
+ if (p.domains !== undefined && p.interface === undefined && p.packageUsda === undefined) {
127
+ return {
128
+ law: evolveLawFromLockfile(parsed as StableIdLockfile),
129
+ source: file,
130
+ note: `prior is a lockfile — aggregate/field identity is diffed, but directive payload changes cannot be (deploy.json carries the interface).`,
131
+ };
132
+ }
133
+ return { law: evolveLawFromDeployJson(parsed as never), source: file };
134
+ }
135
+
136
+ /** Find a `*.deploy.json` under a directory — directly, or one level down in `build/`. */
137
+ function findDeployJson(dir: string): string | undefined {
138
+ const scan = (d: string): string | undefined => {
139
+ if (!existsSync(d)) return undefined;
140
+ const hit = readdirSync(d).find((f) => f.endsWith(".deploy.json"));
141
+ return hit !== undefined ? path.join(d, hit) : undefined;
142
+ };
143
+ return scan(dir) ?? scan(path.join(dir, "build"));
144
+ }
145
+
146
+ /** Read the committed `nomos.stable-ids.json` lockfile beside the config (the CURRENT identity). */
147
+ function loadCwdLockfile(configArg: string): StableIdLockfile | undefined {
148
+ const cfgDir = path.dirname(path.resolve(process.cwd(), configArg));
149
+ const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
150
+ if (!existsSync(lockPath)) return undefined;
151
+ try {
152
+ return JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLockfile;
153
+ } catch {
154
+ return undefined;
155
+ }
65
156
  }
66
157
 
67
158
  async function main(): Promise<void> {
68
159
  const argv = process.argv.slice(2);
69
- const configArg = argv.find((a) => !a.startsWith("--")) ?? "nomos.package.mjs";
70
- const modules = await loadComposedModules(configArg);
71
- const findings = checkDomainModules(modules);
160
+ // The positional config must SKIP flag VALUES (`check --against prior.json` has no
161
+ // positional — `prior.json` belongs to the flag, not the config slot).
162
+ const againstIdx = argv.findIndex((a) => a === "--against");
163
+ const positionals = argv.filter((a, i) => !a.startsWith("--") && i !== againstIdx + 1);
164
+ const configArg = positionals[0] ?? "nomos.package.mjs";
165
+ const againstArg =
166
+ againstIdx >= 0
167
+ ? argv[againstIdx + 1]
168
+ : argv.find((a) => a.startsWith("--against="))?.slice("--against=".length);
169
+
170
+ const asJson = argv.includes("--json");
171
+
172
+ const { modules, domainFiles } = await loadComposedModulesWithSources(configArg);
173
+ const findings: Finding[] = [...checkDomainModules(modules)];
174
+
175
+ // ── EVOLVE PREVIEW (--against): diff the current build against a prior law + pre-fire the gate. ──
176
+ let evolveDispositions: Record<string, unknown> | undefined;
177
+ if (againstIdx >= 0 || againstArg !== undefined) {
178
+ if (againstArg === undefined || againstArg.startsWith("--")) {
179
+ fail(`--against needs a path: --against <build/<name>.deploy.json | build dir | nomos.stable-ids.json>`);
180
+ }
181
+ const lock = loadCwdLockfile(configArg);
182
+ if (lock === undefined && !asJson) {
183
+ console.log(
184
+ "githolon check: --against — no nomos.stable-ids.json in the config dir; the current law's stable ids " +
185
+ "fall back to NAMES (a rename would read as removal+add). Run `githolon compile` first for a sid-accurate preview.",
186
+ );
187
+ }
188
+ const prior = loadPriorEvolveLaw(againstArg);
189
+ if (prior.note !== undefined && !asJson) console.log(`githolon check: --against note — ${prior.note}`);
190
+ const current = currentEvolveLaw(modules, lock);
191
+ const diff = evolveDiff(prior.law, current);
192
+ findings.push(...diff.findings);
193
+ if (Object.keys(diff.dispositions).length > 0) evolveDispositions = diff.dispositions;
194
+ if (!asJson) console.log(`githolon check: evolve preview against ${prior.source}`);
195
+ }
196
+
197
+ // ── --json: emit the findings as a machine-readable array (the LSP + tooling surface). Each finding
198
+ // is enriched with its source {file, line, column} when the source map can locate the declaration.
199
+ if (asJson) {
200
+ const srcMap = new LawSourceMap(domainFiles);
201
+ const out = findings.map((f) => {
202
+ const loc = srcMap.locate(f.domain, f.directiveId);
203
+ return {
204
+ domain: f.domain,
205
+ ...(f.directiveId !== undefined ? { directiveId: f.directiveId } : {}),
206
+ level: f.level,
207
+ what: f.what,
208
+ fix: f.fix,
209
+ ...(loc !== undefined ? { file: loc.file, line: loc.line, column: loc.column, fallback: loc.fallback } : {}),
210
+ };
211
+ });
212
+ process.stdout.write(JSON.stringify(out) + "\n");
213
+ process.exit(hasRefusal(findings) ? 1 : 0);
214
+ }
72
215
 
73
216
  console.log(`githolon check: ${findings.length} finding(s)`);
74
217
  for (const line of formatFindings(findings)) {
@@ -77,6 +220,14 @@ async function main(): Promise<void> {
77
220
  }
78
221
  if (findings.length === 0) console.log(" all clear — no static twin flagged anything");
79
222
 
223
+ // The consolidated, pasteable dispositions the destructive evolves demand (per domain).
224
+ if (evolveDispositions !== undefined) {
225
+ console.log(
226
+ "\ngitholon check: to admit the destructive evolve, add `dispositions` to the domain's deploy JSON:",
227
+ );
228
+ console.log(JSON.stringify(evolveDispositions, null, 2));
229
+ }
230
+
80
231
  process.exit(hasRefusal(findings) ? 1 : 0);
81
232
  }
82
233