@ontrails/warden 1.0.0-beta.30 → 1.0.0-beta.39

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +23 -1
  3. package/bin/warden.ts +1 -0
  4. package/package.json +9 -9
  5. package/src/cli.ts +160 -14
  6. package/src/command.ts +48 -5
  7. package/src/config.ts +9 -0
  8. package/src/drift.ts +116 -7
  9. package/src/fix.ts +8 -2
  10. package/src/formatters.ts +3 -8
  11. package/src/index.ts +32 -2
  12. package/src/project-context.ts +286 -3
  13. package/src/rules/ast.ts +4 -0
  14. package/src/rules/duplicate-exported-symbol.ts +211 -0
  15. package/src/rules/duplicate-public-contract.ts +2 -1
  16. package/src/rules/governed-symbol-residue.ts +131 -0
  17. package/src/rules/implementation-returns-result.ts +1 -3
  18. package/src/rules/index.ts +44 -3
  19. package/src/rules/metadata.ts +108 -14
  20. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  21. package/src/rules/no-retired-cross-vocabulary.ts +19 -9
  22. package/src/rules/permit-governance.ts +1 -1
  23. package/src/rules/public-export-example-coverage.ts +5 -0
  24. package/src/rules/public-internal-deep-imports.ts +3 -63
  25. package/src/rules/registry-names.ts +12 -2
  26. package/src/rules/retired-vocabulary.ts +396 -0
  27. package/src/rules/signal-graph-coaching.ts +32 -3
  28. package/src/rules/surface-overlay-coherence.ts +262 -0
  29. package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +46 -42
  30. package/src/rules/trail-fork-coaching.ts +1 -1
  31. package/src/rules/trailhead-override-divergence.ts +356 -0
  32. package/src/rules/types.ts +84 -4
  33. package/src/rules/webhook-route-collision.ts +64 -1
  34. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  35. package/src/trails/duplicate-public-contract.trail.ts +1 -1
  36. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  37. package/src/trails/index.ts +6 -1
  38. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  39. package/src/trails/schema.ts +39 -0
  40. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  41. package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +5 -5
  42. package/src/trails/trail-fork-coaching.trail.ts +1 -1
  43. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  44. package/src/trails/wrap-rule.ts +10 -0
  45. package/src/workspaces.ts +30 -69
package/src/command.ts CHANGED
@@ -12,8 +12,10 @@ import type {
12
12
  CliFlagValueAlias,
13
13
  CliFlagValueAliasDeclaration,
14
14
  } from '@ontrails/cli';
15
+ import { resolveTrailsOverlays } from '@ontrails/adapter-kit';
15
16
  import type { Topo } from '@ontrails/core';
16
17
  import { AmbiguousError, NotFoundError } from '@ontrails/core';
18
+ import type { TopoGraphOverlayRegistration } from '@ontrails/topographer';
17
19
  import {
18
20
  loadTrailsConfigValue,
19
21
  resolveTrailsProjectRoot,
@@ -26,6 +28,7 @@ import type {
26
28
  WardenDraftsMode,
27
29
  WardenFailOn,
28
30
  WardenFormat,
31
+ WardenScope,
29
32
  WardenLockMode,
30
33
  } from './config.js';
31
34
  import {
@@ -57,6 +60,7 @@ interface MutableWardenConfigLayer {
57
60
  drafts?: WardenDraftsMode | undefined;
58
61
  failOn?: WardenFailOn | undefined;
59
62
  format?: WardenFormat | undefined;
63
+ scope?: WardenScope | undefined;
60
64
  lock?: WardenLockMode | undefined;
61
65
  noLockMutation?: boolean | undefined;
62
66
  }
@@ -95,6 +99,12 @@ const splitApps = (value: string): readonly string[] =>
95
99
  .map((entry) => entry.trim())
96
100
  .filter((entry) => entry.length > 0);
97
101
 
102
+ const splitCommaDelimitedValues = (value: string): readonly string[] =>
103
+ value
104
+ .split(',')
105
+ .map((entry) => entry.trim())
106
+ .filter((entry) => entry.length > 0);
107
+
98
108
  const isAllowedValue = <T extends string>(
99
109
  value: string,
100
110
  allowed: readonly T[]
@@ -248,6 +258,7 @@ const parseTokens = (
248
258
  'no-lock-mutation': { type: 'boolean' },
249
259
  'pre-push': { type: 'boolean' },
250
260
  'root-dir': { type: 'string' },
261
+ 'scope-exclude': { multiple: true, type: 'string' },
251
262
  strict: { type: 'boolean' },
252
263
  ...valueAliasParseOptions,
253
264
  },
@@ -397,6 +408,19 @@ const applyCommandOption = (
397
408
  state.configPath = value;
398
409
  return;
399
410
  }
411
+ if (token.name === 'scope-exclude') {
412
+ if (value === undefined) {
413
+ state.diagnostics.push(
414
+ diagnostic({ message: '--scope-exclude requires a path glob.' })
415
+ );
416
+ return;
417
+ }
418
+ const existing = state.cli.scope?.exclude ?? [];
419
+ state.cli.scope = {
420
+ exclude: [...existing, ...splitCommaDelimitedValues(value)],
421
+ };
422
+ return;
423
+ }
400
424
  if (token.name === 'fix') {
401
425
  state.fix = true;
402
426
  return;
@@ -660,12 +684,22 @@ const resolveNamedAppModulePath = (
660
684
  : resolveFilesystemModulePath(rootDir, matched);
661
685
  };
662
686
 
663
- const importTopoFromModulePath = async (modulePath: string): Promise<Topo> => {
687
+ interface LoadedWardenTopoModule {
688
+ /** App-module overlay registrations read through the shared channel. */
689
+ readonly overlays?: readonly TopoGraphOverlayRegistration[] | undefined;
690
+ readonly topo: Topo;
691
+ }
692
+
693
+ const importTopoFromModulePath = async (
694
+ modulePath: string
695
+ ): Promise<LoadedWardenTopoModule> => {
664
696
  const loaded = (await import(pathToFileURL(modulePath).href)) as Record<
665
697
  string,
666
698
  unknown
667
699
  >;
668
- return extractTopo(modulePath, loaded);
700
+ const topo = extractTopo(modulePath, loaded);
701
+ const overlays = resolveTrailsOverlays(loaded, modulePath);
702
+ return overlays === undefined ? { topo } : { overlays, topo };
669
703
  };
670
704
 
671
705
  const topoLoadDiagnostic = ({
@@ -737,9 +771,11 @@ export const resolveWardenTopoTargets = async ({
737
771
  for (const appName of apps) {
738
772
  try {
739
773
  const modulePath = resolveNamedAppModulePath(rootDir, appName);
774
+ const loaded = await importTopoFromModulePath(modulePath);
740
775
  topos.push({
741
776
  name: appName,
742
- topo: await importTopoFromModulePath(modulePath),
777
+ overlays: loaded.overlays,
778
+ topo: loaded.topo,
743
779
  });
744
780
  } catch (error) {
745
781
  diagnostics.push(
@@ -756,10 +792,16 @@ export const resolveWardenTopoTargets = async ({
756
792
 
757
793
  try {
758
794
  const modulePath = resolveDiscoveredModulePath(rootDir);
759
- const topo = await importTopoFromModulePath(modulePath);
795
+ const loaded = await importTopoFromModulePath(modulePath);
760
796
  return {
761
797
  diagnostics,
762
- topos: [{ name: topo.name, topo }],
798
+ topos: [
799
+ {
800
+ name: loaded.topo.name,
801
+ overlays: loaded.overlays,
802
+ topo: loaded.topo,
803
+ },
804
+ ],
763
805
  };
764
806
  } catch (error) {
765
807
  if (error instanceof NotFoundError) {
@@ -821,6 +863,7 @@ const buildRunOptions = ({
821
863
  lock: cli.lock,
822
864
  noLockMutation: cli.noLockMutation,
823
865
  rootDir,
866
+ scope: cli.scope,
824
867
  topos,
825
868
  }),
826
869
  env,
package/src/config.ts CHANGED
@@ -10,6 +10,13 @@ export const wardenDraftsValues = ['include', 'exclude', 'only'] as const;
10
10
 
11
11
  const appNameSchema = z.string().min(1);
12
12
 
13
+ const wardenScopeSchema = z
14
+ .object({
15
+ exclude: z.array(z.string().min(1)).default([]),
16
+ })
17
+ .strict()
18
+ .default({ exclude: [] });
19
+
13
20
  const wardenConfigObjectSchema = z
14
21
  .object({
15
22
  apps: z.array(appNameSchema).min(1).optional(),
@@ -18,6 +25,7 @@ const wardenConfigObjectSchema = z
18
25
  failOn: z.enum(wardenFailOnValues).default('error'),
19
26
  format: z.enum(wardenFormatValues).default('summary'),
20
27
  lock: z.enum(wardenLockValues).default('auto'),
28
+ scope: wardenScopeSchema,
21
29
  })
22
30
  .strict();
23
31
 
@@ -31,6 +39,7 @@ export type WardenDepth = (typeof wardenDepthValues)[number];
31
39
  export type WardenDraftsMode = (typeof wardenDraftsValues)[number];
32
40
  export type WardenFailOn = (typeof wardenFailOnValues)[number];
33
41
  export type WardenFormat = (typeof wardenFormatValues)[number];
42
+ export type WardenScope = z.output<typeof wardenScopeSchema>;
34
43
  export type WardenLockMode = (typeof wardenLockValues)[number];
35
44
 
36
45
  export interface WardenConfigLayer extends Partial<WardenConfig> {
package/src/drift.ts CHANGED
@@ -15,14 +15,33 @@ import {
15
15
  ValidationError,
16
16
  } from '@ontrails/core';
17
17
  import {
18
+ collectTopoGraphOverlays,
18
19
  createTopoStore,
19
20
  deriveTopoGraph,
20
21
  deriveTopoGraphHash,
21
22
  isTopoArtifactRegenerationError,
22
23
  readLockManifest,
24
+ readTopoGraph,
23
25
  readTrailsLock,
24
26
  } from '@ontrails/topographer';
25
- import type { LockManifest } from '@ontrails/topographer';
27
+ import type {
28
+ DeriveTopoGraphOptions,
29
+ LockManifest,
30
+ TopoGraphOverlays,
31
+ } from '@ontrails/topographer';
32
+
33
+ /**
34
+ * Derive options `checkDrift` accepts so the fresh comparison graph carries
35
+ * the same app-module overlays the committed lock embeds.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import type { CheckDriftOptions } from '@ontrails/warden';
40
+ *
41
+ * const options: CheckDriftOptions = { overlays: lease.overlays };
42
+ * ```
43
+ */
44
+ export type CheckDriftOptions = Pick<DeriveTopoGraphOptions, 'overlays'>;
26
45
 
27
46
  /**
28
47
  * Result of a drift check comparing committed trails.lock against the current state.
@@ -36,6 +55,12 @@ export interface DriftResult {
36
55
  readonly committedHash: string | null;
37
56
  /** Hash computed from the current trail topology */
38
57
  readonly currentHash: string;
58
+ /**
59
+ * Overlay namespaces whose committed facts differ from the freshly derived
60
+ * facts, sorted lexicographically. Present only when the lock is stale and
61
+ * the caller supplied a topo plus derive options.
62
+ */
63
+ readonly driftedOverlayNamespaces?: readonly string[] | undefined;
39
64
  }
40
65
 
41
66
  interface BlockedLockRead {
@@ -91,14 +116,89 @@ const isBlockedLockRead = (
91
116
  ): result is BlockedLockRead =>
92
117
  result !== null && 'kind' in result && result.kind === 'blocked-lock-read';
93
118
 
119
+ /**
120
+ * Read the committed lock's embedded graph overlays, tolerating both the v4
121
+ * root `trails.lock` envelope and the legacy `.trails/` artifact layout.
122
+ */
123
+ const readCommittedGraphOverlays = async (
124
+ rootDir: string
125
+ ): Promise<TopoGraphOverlays | undefined> => {
126
+ const committedGraph =
127
+ (await readTopoGraph({ dir: rootDir })) ??
128
+ (await readTopoGraph({ dir: deriveTrailsDir({ rootDir }) }));
129
+ return committedGraph?.overlays;
130
+ };
131
+
132
+ /**
133
+ * Name the overlay namespaces whose committed facts drifted from the freshly
134
+ * derived facts. Compares canonical JSON per namespace across the union of
135
+ * committed and current namespaces; returns a sorted list.
136
+ */
137
+ const collectDriftedOverlayNamespaces = async (
138
+ rootDir: string,
139
+ topo: Topo,
140
+ options: CheckDriftOptions
141
+ ): Promise<readonly string[]> => {
142
+ let committed: TopoGraphOverlays | undefined;
143
+ try {
144
+ committed = await readCommittedGraphOverlays(rootDir);
145
+ } catch {
146
+ return [];
147
+ }
148
+ const current = collectTopoGraphOverlays(topo, options.overlays);
149
+ const namespaces = new Set([
150
+ ...Object.keys(committed ?? {}),
151
+ ...Object.keys(current ?? {}),
152
+ ]);
153
+ return [...namespaces]
154
+ .filter(
155
+ (namespace) =>
156
+ JSON.stringify(committed?.[namespace]) !==
157
+ JSON.stringify(current?.[namespace])
158
+ )
159
+ .toSorted();
160
+ };
161
+
162
+ /**
163
+ * Format a stale drift result into one human-readable sentence.
164
+ *
165
+ * Names the drifted overlay namespaces when the drift check identified them,
166
+ * and always points at `trails compile` as the remediation.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * import { staleDriftMessage } from './drift.js';
171
+ *
172
+ * staleDriftMessage({
173
+ * committedHash: 'aaa',
174
+ * currentHash: 'bbb',
175
+ * driftedOverlayNamespaces: ['surfaces'],
176
+ * stale: true,
177
+ * });
178
+ * // => 'trails.lock is stale — drifted overlay namespaces: surfaces (regenerate with `trails compile`)'
179
+ * ```
180
+ */
181
+ export const staleDriftMessage = (drift: DriftResult): string => {
182
+ const namespaces = drift.driftedOverlayNamespaces;
183
+ const detail =
184
+ namespaces !== undefined && namespaces.length > 0
185
+ ? ` — drifted overlay namespaces: ${namespaces.join(', ')}`
186
+ : '';
187
+ return `trails.lock is stale${detail} (regenerate with \`trails compile\`)`;
188
+ };
189
+
94
190
  /**
95
191
  * Check whether the committed trails.lock is stale compared to the current topology.
96
192
  *
97
193
  * When no topo is provided, returns a clean result (no drift detectable without runtime info).
194
+ * When a topo is provided, `options.overlays` carries the app-module overlay
195
+ * registrations so the fresh comparison graph embeds the same namespaced
196
+ * facts the compile path writes into the committed lock.
98
197
  */
99
198
  export const checkDrift = async (
100
199
  rootDir: string,
101
- topo?: Topo | undefined
200
+ topo?: Topo | undefined,
201
+ options?: CheckDriftOptions | undefined
102
202
  ): Promise<DriftResult> => {
103
203
  try {
104
204
  const lockManifest = await readCommittedLockManifest(rootDir);
@@ -127,15 +227,24 @@ export const checkDrift = async (
127
227
  const currentHash =
128
228
  topo === undefined
129
229
  ? (readStoredHash() ?? 'unknown')
130
- : deriveTopoGraphHash(deriveTopoGraph(topo));
230
+ : deriveTopoGraphHash(deriveTopoGraph(topo, options));
231
+ const stale =
232
+ topoArtifact !== null &&
233
+ currentHash !== 'unknown' &&
234
+ topoArtifact.sha256 !== currentHash;
235
+ const driftedOverlayNamespaces =
236
+ stale && topo !== undefined && options !== undefined
237
+ ? await collectDriftedOverlayNamespaces(rootDir, topo, options)
238
+ : undefined;
131
239
 
132
240
  return {
133
241
  committedHash: topoArtifact?.sha256 ?? null,
134
242
  currentHash,
135
- stale:
136
- topoArtifact !== null &&
137
- currentHash !== 'unknown' &&
138
- topoArtifact.sha256 !== currentHash,
243
+ ...(driftedOverlayNamespaces === undefined ||
244
+ driftedOverlayNamespaces.length === 0
245
+ ? {}
246
+ : { driftedOverlayNamespaces }),
247
+ stale,
139
248
  };
140
249
  } catch (error) {
141
250
  if (
package/src/fix.ts CHANGED
@@ -95,16 +95,22 @@ export const applySafeFixesToSource = (
95
95
  const applied: WardenDiagnostic[] = [];
96
96
  const skipped: WardenDiagnostic[] = [];
97
97
  const edits: ResolvedEdit[] = [];
98
+ const seenEdits = new Set<string>();
98
99
 
99
100
  for (const diagnostic of diagnostics) {
100
101
  if (hasSafeFixEdits(diagnostic)) {
101
102
  applied.push(diagnostic);
102
103
  for (const edit of diagnostic.fix.edits) {
103
- edits.push({
104
+ const resolvedEdit = {
104
105
  end: edit.end,
105
106
  replacement: edit.replacement,
106
107
  start: edit.start,
107
- });
108
+ };
109
+ const key = `${String(resolvedEdit.start)}\0${String(resolvedEdit.end)}\0${resolvedEdit.replacement}`;
110
+ if (!seenEdits.has(key)) {
111
+ seenEdits.add(key);
112
+ edits.push(resolvedEdit);
113
+ }
108
114
  }
109
115
  } else {
110
116
  skipped.push(diagnostic);
package/src/formatters.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { WardenReport } from './cli.js';
10
+ import { staleDriftMessage } from './drift.js';
10
11
  import type { WardenGuidanceLink, WardenSeverity } from './rules/types.js';
11
12
 
12
13
  /** Map warden severity to GitHub Actions annotation level. */
@@ -35,9 +36,7 @@ export const formatGitHubAnnotations = (report: WardenReport): string => {
35
36
  if (report.drift?.blockedReason !== undefined) {
36
37
  lines.push(`::error::drift: ${report.drift.blockedReason}`);
37
38
  } else if (report.drift?.stale) {
38
- lines.push(
39
- '::error::drift: trails.lock is stale (regenerate with `trails compile`)'
40
- );
39
+ lines.push(`::error::drift: ${staleDriftMessage(report.drift)}`);
41
40
  }
42
41
 
43
42
  return lines.join('\n');
@@ -144,11 +143,7 @@ const driftSection = (drift: WardenReport['drift']): readonly string[] => {
144
143
  if (!drift?.stale) {
145
144
  return [];
146
145
  }
147
- return [
148
- '',
149
- '### Drift',
150
- '- trails.lock is stale (regenerate with `trails compile`)',
151
- ];
146
+ return ['', '### Drift', `- ${staleDriftMessage(drift)}`];
152
147
  };
153
148
 
154
149
  /** Render safe-fix counts when a fix pass was requested. */
package/src/index.ts CHANGED
@@ -14,10 +14,12 @@ export type {
14
14
  ProjectContext,
15
15
  TopoAwareWardenRule,
16
16
  WardenDiagnostic,
17
+ WardenExportedSymbolDefinition,
17
18
  WardenFix,
18
19
  WardenFixCapability,
19
20
  WardenFixClass,
20
21
  WardenFixEdit,
22
+ WardenFixScanTargets,
21
23
  WardenFixSafety,
22
24
  WardenGuidance,
23
25
  WardenGuidanceLink,
@@ -34,8 +36,21 @@ export type {
34
36
  // Rule registry
35
37
  export {
36
38
  builtinWardenRuleMetadata,
39
+ formatGovernedVocabularyTransitionGuide,
40
+ getGovernedVocabularyTransition,
41
+ governedVocabularyLiteralRenameSchema,
42
+ governedVocabularyPreserveRuleSchema,
43
+ governedVocabularyRegistrySchema,
44
+ governedVocabularyScopeSchema,
45
+ governedVocabularySymbolRenameSchema,
46
+ governedVocabularyTargetSchema,
47
+ governedVocabularyTransitionSchema,
48
+ governedVocabularyTransitionStatuses,
49
+ governedVocabularyTransitions,
37
50
  getWardenRuleMetadata,
38
51
  listWardenRuleMetadata,
52
+ listGovernedVocabularyTransitions,
53
+ requireGovernedVocabularyTransition,
39
54
  wardenFixClasses,
40
55
  wardenFixSafeties,
41
56
  wardenRuleConcerns,
@@ -45,6 +60,15 @@ export {
45
60
  wardenRules,
46
61
  wardenTopoRules,
47
62
  } from './rules/index.js';
63
+ export type {
64
+ GovernedVocabularyLiteralRename,
65
+ GovernedVocabularyPreserveRule,
66
+ GovernedVocabularyScope,
67
+ GovernedVocabularySymbolRename,
68
+ GovernedVocabularyTarget,
69
+ GovernedVocabularyTransition,
70
+ GovernedVocabularyTransitionInput,
71
+ } from './rules/index.js';
48
72
 
49
73
  // Rule-scoped cache controls for long-lived consumers (watch mode, LSPs).
50
74
  export { clearImplementationReturnsResultCache } from './rules/implementation-returns-result.js';
@@ -100,6 +124,7 @@ export type {
100
124
  WardenDraftsMode,
101
125
  WardenFailOn,
102
126
  WardenFormat,
127
+ WardenScope,
103
128
  WardenLockMode,
104
129
  EffectiveWardenConfig,
105
130
  ResolveWardenConfigOptions,
@@ -116,7 +141,7 @@ export {
116
141
  } from './formatters.js';
117
142
 
118
143
  // Drift detection
119
- export type { DriftResult } from './drift.js';
144
+ export type { CheckDriftOptions, DriftResult } from './drift.js';
120
145
  export { checkDrift } from './drift.js';
121
146
 
122
147
  // Project-local rule loading
@@ -174,6 +199,7 @@ export {
174
199
  deadPublicTrailTrail,
175
200
  deprecationWithoutGuidanceTrail,
176
201
  diagnosticSchema,
202
+ duplicateExportedSymbolTrail,
177
203
  duplicatePublicContractTrail,
178
204
  draftFileMarkingTrail,
179
205
  draftVisibleDebtTrail,
@@ -181,6 +207,7 @@ export {
181
207
  exampleValidTrail,
182
208
  firesDeclarationsTrail,
183
209
  forkWithoutPreservedBlazeTrail,
210
+ governedSymbolResidueTrail,
184
211
  implementationReturnsResultTrail,
185
212
  incompleteAccessorForStandardOpTrail,
186
213
  incompleteCrudTrail,
@@ -193,6 +220,7 @@ export {
193
220
  noDevPermitInSourceTrail,
194
221
  noDestructuredComposeTrail,
195
222
  noDirectImplementationCallTrail,
223
+ noLegacyCliAliasExportTrail,
196
224
  noLegacyLayerImportsTrail,
197
225
  noNativeErrorResultTrail,
198
226
  noRedundantResultErrorWrapTrail,
@@ -224,8 +252,10 @@ export {
224
252
  scheduledDestroyIntentTrail,
225
253
  signalGraphCoachingTrail,
226
254
  staticResourceAccessorPreferenceTrail,
227
- surfaceFacetCoherenceTrail,
255
+ surfaceOverlayCoherenceTrail,
256
+ surfaceTrailheadCoherenceTrail,
228
257
  trailForkCoachingTrail,
258
+ trailheadOverrideDivergenceTrail,
229
259
  unmaterializedActivationSourceTrail,
230
260
  topoAwareRuleInput,
231
261
  unreachableDetourShadowingTrail,