@ontrails/warden 1.0.0-beta.18 → 1.0.0-beta.19

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 (73) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +12 -30
  3. package/bin/warden.ts +29 -1
  4. package/package.json +9 -8
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +238 -60
  7. package/src/command.ts +26 -0
  8. package/src/drift.ts +1 -1
  9. package/src/fix.ts +120 -0
  10. package/src/formatters.ts +14 -2
  11. package/src/guide.ts +11 -0
  12. package/src/index.ts +31 -1
  13. package/src/rules/ast.ts +84 -25
  14. package/src/rules/circular-refs.ts +1 -1
  15. package/src/rules/{cross-declarations.ts → composes-declarations.ts} +198 -89
  16. package/src/rules/context-no-surface-types.ts +4 -4
  17. package/src/rules/contour-exists.ts +1 -1
  18. package/src/rules/dead-internal-trail.ts +22 -9
  19. package/src/rules/fires-declarations.ts +3 -3
  20. package/src/rules/implementation-returns-result.ts +269 -76
  21. package/src/rules/index.ts +51 -3
  22. package/src/rules/intent-propagation.ts +6 -6
  23. package/src/rules/metadata.ts +117 -12
  24. package/src/rules/missing-visibility.ts +14 -14
  25. package/src/rules/no-destructured-compose.ts +192 -0
  26. package/src/rules/no-direct-implementation-call.ts +2 -2
  27. package/src/rules/no-legacy-layer-imports.ts +19 -1
  28. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  29. package/src/rules/no-sync-result-assumption.ts +2 -2
  30. package/src/rules/no-throw-in-implementation.ts +2 -3
  31. package/src/rules/no-top-level-surface.ts +389 -0
  32. package/src/rules/on-references-exist.ts +1 -1
  33. package/src/rules/reference-exists.ts +1 -1
  34. package/src/rules/registry-names.ts +28 -2
  35. package/src/rules/resolved-import-boundary.ts +2 -2
  36. package/src/rules/resource-declarations.ts +4 -4
  37. package/src/rules/resource-exists.ts +1 -1
  38. package/src/rules/resource-mock-coverage.ts +115 -0
  39. package/src/rules/scan.ts +39 -0
  40. package/src/rules/trail-versioning-source.ts +1094 -0
  41. package/src/rules/trail-versioning-topo.ts +172 -0
  42. package/src/rules/types.ts +87 -5
  43. package/src/rules/valid-detour-contract.ts +1 -1
  44. package/src/rules/warden-export-symmetry.ts +1 -1
  45. package/src/rules/warden-rules-use-ast.ts +2 -2
  46. package/src/trails/activation-orphan.trail.ts +4 -1
  47. package/src/trails/composes-declarations.trail.ts +22 -0
  48. package/src/trails/dead-internal-trail.trail.ts +4 -4
  49. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  50. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  51. package/src/trails/index.ts +12 -1
  52. package/src/trails/intent-propagation.trail.ts +3 -3
  53. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  54. package/src/trails/missing-visibility.trail.ts +2 -2
  55. package/src/trails/no-destructured-compose.trail.ts +44 -0
  56. package/src/trails/no-direct-implementation-call.trail.ts +2 -2
  57. package/src/trails/no-legacy-layer-imports.trail.ts +6 -0
  58. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  59. package/src/trails/no-top-level-surface.trail.ts +43 -0
  60. package/src/trails/pending-force.trail.ts +21 -0
  61. package/src/trails/public-internal-deep-imports.trail.ts +1 -1
  62. package/src/trails/resolved-import-boundary.trail.ts +4 -4
  63. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  64. package/src/trails/run.ts +2 -2
  65. package/src/trails/schema.ts +32 -6
  66. package/src/trails/signal-graph-coaching.trail.ts +4 -1
  67. package/src/trails/unmaterialized-activation-source.trail.ts +4 -1
  68. package/src/trails/valid-detour-contract.trail.ts +1 -1
  69. package/src/trails/version-gap.trail.ts +35 -0
  70. package/src/trails/version-pinned-compose.trail.ts +23 -0
  71. package/src/trails/version-without-examples.trail.ts +38 -0
  72. package/src/trails/wrap-rule.ts +5 -3
  73. package/src/trails/cross-declarations.trail.ts +0 -22
package/src/cli.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  * and returns a structured report.
6
6
  */
7
7
 
8
- import { resolve } from 'node:path';
8
+ import { isAbsolute, relative, resolve } from 'node:path';
9
9
 
10
10
  import type { Topo } from '@ontrails/core';
11
+ import { deriveTopoGraph } from '@ontrails/topographer';
12
+ import type { TopoGraph } from '@ontrails/topographer';
11
13
  import { getContourReferences } from '@ontrails/core';
12
14
 
13
15
  import type {
@@ -19,8 +21,10 @@ import type {
19
21
  WardenFormat,
20
22
  WardenLockMode,
21
23
  } from './config.js';
24
+ import { runWardenAdapterChecks } from './adapter-check.js';
22
25
  import { resolveWardenConfig } from './config.js';
23
26
  import { isDraftMarkedFile } from './draft.js';
27
+ import { applySafeFixesToSource, hasSafeFixEdits } from './fix.js';
24
28
  import type { DriftResult } from './drift.js';
25
29
  import { checkDrift } from './drift.js';
26
30
  import {
@@ -32,7 +36,7 @@ import {
32
36
  collectContourDefinitionIds,
33
37
  collectContourReferenceTargetsByName,
34
38
  collectCrudTableIds as collectCrudTableIdsFromAst,
35
- collectCrossTargetTrailIds,
39
+ collectComposeTargetTrailIds,
36
40
  collectOnTargetSignalIds as collectOnTargetSignalIdsFromAst,
37
41
  collectReconcileTableIds as collectReconcileTableIdsFromAst,
38
42
  collectResourceDefinitionIds,
@@ -44,6 +48,10 @@ import {
44
48
  import { collectFileCrudCoverage } from './rules/incomplete-crud.js';
45
49
  import { wardenRules, wardenTopoRules } from './rules/index.js';
46
50
  import { getWardenRuleMetadata } from './rules/metadata.js';
51
+ import {
52
+ isWardenDevPermitTestScanTarget,
53
+ isWardenSourceScanTarget,
54
+ } from './rules/scan.js';
47
55
  import type {
48
56
  ProjectAwareWardenRule,
49
57
  ProjectContext,
@@ -59,6 +67,8 @@ import type { WardenImportResolution } from './resolve.js';
59
67
  * Resolved topo input for Warden runs that govern multiple apps.
60
68
  */
61
69
  export interface WardenTopoTarget {
70
+ /** Optional precomputed topo graph, including graph-only audit annotations. */
71
+ readonly graph?: TopoGraph | undefined;
62
72
  /** Stable app/topo label used to tag topo-aware diagnostics. */
63
73
  readonly name?: string | undefined;
64
74
  /** Resolved topo module to inspect. */
@@ -75,12 +85,21 @@ export interface WardenRunOptions {
75
85
  readonly config?: WardenConfigInput | undefined;
76
86
  /** CLI/config-layer app names carried through shared resolution. */
77
87
  readonly apps?: readonly string[] | undefined;
88
+ /** Include shared adapter authoring checks as Warden diagnostics. */
89
+ readonly adapterCheck?: boolean | undefined;
78
90
  /** Cumulative analysis depth for the final M1 surfaces. */
79
91
  readonly depth?: WardenDepth | undefined;
80
92
  /** Draft-state handling mode for final M1 surfaces. */
81
93
  readonly drafts?: EffectiveWardenConfig['drafts'] | undefined;
82
94
  /** Failure threshold used to compute `report.passed`. */
83
95
  readonly failOn?: WardenFailOn | undefined;
96
+ /**
97
+ * Apply safe source fixes among the run's diagnostics, writing changed files.
98
+ *
99
+ * Only `safety: 'safe'` fixes with concrete edits are applied; review-required,
100
+ * edit-less, and topo diagnostics stay reported but unapplied.
101
+ */
102
+ readonly fix?: boolean | undefined;
84
103
  /** Output format requested by the caller. */
85
104
  readonly format?: WardenFormat | undefined;
86
105
  /** Lockfile mode requested by the caller. */
@@ -125,11 +144,35 @@ export interface WardenRunOptions {
125
144
  * when `topo` is also supplied (see `topo` remarks).
126
145
  */
127
146
  readonly extraTopoRules?: readonly TopoAwareWardenRule[] | undefined;
147
+ /**
148
+ * Extra source rules to run in addition to the built-in registry.
149
+ *
150
+ * Primarily a test hook — production callers should register durable rules
151
+ * via `wardenRules` in `rules/index.ts`.
152
+ */
153
+ readonly extraSourceRules?: readonly WardenRule[] | undefined;
128
154
  }
129
155
 
130
156
  /** Backwards-compatible name for older consumers. */
131
157
  export type WardenOptions = WardenRunOptions;
132
158
 
159
+ /**
160
+ * Aggregate outcome of a `--fix` pass over a run's diagnostics.
161
+ */
162
+ export interface WardenFixSummary {
163
+ /** Diagnostics whose safe fix was applied to source. */
164
+ readonly applied: number;
165
+ /** Source files rewritten with patched content. */
166
+ readonly filesChanged: number;
167
+ /** Diagnostics carrying fix metadata left unapplied (review or edit-less). */
168
+ readonly skipped: number;
169
+ }
170
+
171
+ export interface WardenFixApplication extends WardenFixSummary {
172
+ /** Diagnostics removed from the final report because their safe fix applied. */
173
+ readonly appliedDiagnostics: readonly WardenDiagnostic[];
174
+ }
175
+
133
176
  /**
134
177
  * Result of a warden run.
135
178
  */
@@ -148,30 +191,14 @@ export interface WardenReport {
148
191
  readonly effectiveConfig?: EffectiveWardenConfig | undefined;
149
192
  /** Resolved topo/app labels governed by this run. */
150
193
  readonly topoNames?: readonly string[] | undefined;
194
+ /** Safe-fix application summary, present only when a `--fix` pass ran. */
195
+ readonly fixes?: WardenFixSummary | undefined;
151
196
  }
152
197
 
153
198
  /**
154
199
  * Collect Warden scan targets under a directory, excluding generated and test
155
200
  * surfaces that should not contribute most committed-source diagnostics.
156
201
  */
157
- const isInfrastructureScanTarget = (match: string): boolean =>
158
- match.endsWith('.d.ts') ||
159
- match.startsWith('node_modules/') ||
160
- match.startsWith('dist/') ||
161
- match.startsWith('.git/');
162
-
163
- const isTestScanTarget = (match: string): boolean =>
164
- match.includes('__tests__/') ||
165
- match.includes('__test__/') ||
166
- match.endsWith('.test.ts') ||
167
- match.endsWith('.spec.ts');
168
-
169
- const isAllowedScanTarget = (match: string): boolean =>
170
- !isInfrastructureScanTarget(match) && !isTestScanTarget(match);
171
-
172
- const isDevPermitTestScanTarget = (match: string): boolean =>
173
- !isInfrastructureScanTarget(match) && isTestScanTarget(match);
174
-
175
202
  const collectFilesMatching = (
176
203
  dir: string,
177
204
  pattern: string,
@@ -187,7 +214,7 @@ const collectFilesMatching = (
187
214
 
188
215
  const files: string[] = [];
189
216
  for (const match of matches) {
190
- if (isAllowedScanTarget(match)) {
217
+ if (isWardenSourceScanTarget(match)) {
191
218
  files.push(`${dir}/${match}`);
192
219
  }
193
220
  }
@@ -232,7 +259,7 @@ const collectDevPermitTestFiles = (dir: string): readonly string[] => {
232
259
 
233
260
  const files: string[] = [];
234
261
  for (const match of matches) {
235
- if (isDevPermitTestScanTarget(match)) {
262
+ if (isWardenDevPermitTestScanTarget(match)) {
236
263
  files.push(`${dir}/${match}`);
237
264
  }
238
265
  }
@@ -275,7 +302,7 @@ const collectDocumentationFiles = (dir: string): readonly string[] => {
275
302
 
276
303
  const files: string[] = [];
277
304
  for (const match of matches) {
278
- if (isAllowedScanTarget(match) && isDocumentationScanTarget(match)) {
305
+ if (isWardenSourceScanTarget(match) && isDocumentationScanTarget(match)) {
279
306
  files.push(`${dir}/${match}`);
280
307
  }
281
308
  }
@@ -291,7 +318,7 @@ interface SourceFile {
291
318
  interface MutableProjectContext {
292
319
  contourReferencesByName: Map<string, Set<string>>;
293
320
  crudTableIds: Set<string>;
294
- crossTargetTrailIds: Set<string>;
321
+ composeTargetTrailIds: Set<string>;
295
322
  crudCoverageByEntity: Map<string, Set<string>>;
296
323
  knownContourIds: Set<string>;
297
324
  knownResourceIds: Set<string>;
@@ -309,8 +336,8 @@ interface MutableProjectContext {
309
336
  }
310
337
 
311
338
  const createMutableProjectContext = (): MutableProjectContext => ({
339
+ composeTargetTrailIds: new Set<string>(),
312
340
  contourReferencesByName: new Map<string, Set<string>>(),
313
- crossTargetTrailIds: new Set<string>(),
314
341
  crudCoverageByEntity: new Map<string, Set<string>>(),
315
342
  crudTableIds: new Set<string>(),
316
343
  documentedImportResolutionsByFile: new Map<
@@ -369,7 +396,7 @@ const toProjectContext = (context: MutableProjectContext): ProjectContext => ({
369
396
  ),
370
397
  }
371
398
  : {}),
372
- crossTargetTrailIds: context.crossTargetTrailIds,
399
+ composeTargetTrailIds: context.composeTargetTrailIds,
373
400
  knownContourIds: context.knownContourIds,
374
401
  knownResourceIds: context.knownResourceIds,
375
402
  knownSignalIds: context.knownSignalIds,
@@ -423,17 +450,17 @@ const collectKnownTrailIds = (
423
450
  }
424
451
  };
425
452
 
426
- const collectCrossedTrailIds = (
453
+ const collectComposedTrailIds = (
427
454
  sourceCode: string,
428
455
  filePath: string,
429
- crossTargetTrailIds: Set<string>
456
+ composeTargetTrailIds: Set<string>
430
457
  ): void => {
431
458
  const ast = parse(filePath, sourceCode);
432
459
  if (!ast) {
433
460
  return;
434
461
  }
435
- for (const id of collectCrossTargetTrailIds(ast, sourceCode)) {
436
- crossTargetTrailIds.add(id);
462
+ for (const id of collectComposeTargetTrailIds(ast, sourceCode)) {
463
+ composeTargetTrailIds.add(id);
437
464
  }
438
465
  };
439
466
 
@@ -619,14 +646,14 @@ const collectTopoKnownIds = (
619
646
  }
620
647
  };
621
648
 
622
- const collectTopoCrossesAndIntents = (
649
+ const collectTopoComposesAndIntents = (
623
650
  appTopo: Topo,
624
651
  context: MutableProjectContext
625
652
  ): void => {
626
653
  for (const trail of appTopo.trails.values()) {
627
654
  context.trailIntentsById.set(trail.id, trail.intent);
628
- for (const crossedTrailId of trail.crosses) {
629
- context.crossTargetTrailIds.add(crossedTrailId);
655
+ for (const composedTrailId of trail.composes) {
656
+ context.composeTargetTrailIds.add(composedTrailId);
630
657
  }
631
658
  }
632
659
  };
@@ -649,7 +676,7 @@ const collectTopoTrailContext = (
649
676
  context: MutableProjectContext
650
677
  ): void => {
651
678
  collectTopoKnownIds(appTopo, context);
652
- collectTopoCrossesAndIntents(appTopo, context);
679
+ collectTopoComposesAndIntents(appTopo, context);
653
680
  collectTopoContourReferences(appTopo, context);
654
681
  };
655
682
 
@@ -683,10 +710,10 @@ const collectFileTrailRelationships = (
683
710
  sourceFile: SourceFile,
684
711
  context: MutableProjectContext
685
712
  ): void => {
686
- collectCrossedTrailIds(
713
+ collectComposedTrailIds(
687
714
  sourceFile.sourceCode,
688
715
  sourceFile.filePath,
689
- context.crossTargetTrailIds
716
+ context.composeTargetTrailIds
690
717
  );
691
718
  collectTrailIntents(
692
719
  sourceFile.sourceCode,
@@ -952,6 +979,7 @@ const topoRuleFailureDiagnostic = (
952
979
  */
953
980
  const lintTopo = async (
954
981
  appTopo: Topo,
982
+ graph: TopoGraph | undefined,
955
983
  extraTopoRules: readonly TopoAwareWardenRule[],
956
984
  selector: WardenRuleSelector
957
985
  ): Promise<readonly WardenDiagnostic[]> => {
@@ -960,9 +988,21 @@ const lintTopo = async (
960
988
  ...wardenTopoRules.values(),
961
989
  ...extraTopoRules,
962
990
  ].filter((rule) => isSelectedTopoRule(rule, selector));
991
+ let contextGraph: TopoGraph;
992
+ try {
993
+ contextGraph = graph ?? deriveTopoGraph(appTopo);
994
+ } catch (error) {
995
+ for (const rule of rules) {
996
+ diagnostics.push(topoRuleFailureDiagnostic(rule, error));
997
+ }
998
+ return diagnostics;
999
+ }
1000
+
963
1001
  for (const rule of rules) {
964
1002
  try {
965
- diagnostics.push(...(await rule.checkTopo(appTopo)));
1003
+ diagnostics.push(
1004
+ ...(await rule.checkTopo(appTopo, { graph: contextGraph }))
1005
+ );
966
1006
  } catch (error) {
967
1007
  diagnostics.push(topoRuleFailureDiagnostic(rule, error));
968
1008
  }
@@ -973,11 +1013,13 @@ const lintTopo = async (
973
1013
  const lintSourceFiles = (
974
1014
  sourceFiles: readonly SourceFile[],
975
1015
  context: ProjectContext,
1016
+ extraSourceRules: readonly WardenRule[],
976
1017
  selector: WardenRuleSelector
977
1018
  ): readonly WardenDiagnostic[] => {
978
1019
  const diagnostics: WardenDiagnostic[] = [];
1020
+ const rules = [...wardenRules.values(), ...extraSourceRules];
979
1021
  for (const sourceFile of sourceFiles) {
980
- for (const rule of wardenRules.values()) {
1022
+ for (const rule of rules) {
981
1023
  if (
982
1024
  sourceFile.kind === 'text' &&
983
1025
  rule.name !== 'no-dev-permit-in-source' &&
@@ -1032,6 +1074,7 @@ const lintTopoTargets = async (
1032
1074
  for (const target of topoTargets) {
1033
1075
  const topoDiagnostics = await lintTopo(
1034
1076
  target.topo,
1077
+ target.graph,
1035
1078
  extraTopoRules,
1036
1079
  selector
1037
1080
  );
@@ -1059,17 +1102,26 @@ const selectorIncludesTopoRules = (selector: WardenRuleSelector): boolean => {
1059
1102
  /**
1060
1103
  * Lint all files against all warden rules.
1061
1104
  */
1105
+ interface WardenLintResult {
1106
+ readonly diagnostics: readonly WardenDiagnostic[];
1107
+ readonly sourceFiles: readonly SourceFile[];
1108
+ }
1109
+
1062
1110
  const lintFiles = async (
1063
1111
  rootDir: string,
1064
1112
  drafts: EffectiveWardenConfig['drafts'],
1065
1113
  topoTargets: readonly WardenTopoTarget[],
1066
1114
  extraTopoRules: readonly TopoAwareWardenRule[],
1115
+ extraSourceRules: readonly WardenRule[],
1067
1116
  selector: WardenRuleSelector
1068
- ): Promise<WardenDiagnostic[]> => {
1117
+ ): Promise<WardenLintResult> => {
1069
1118
  if (selector.tier === 'topo-aware') {
1070
- return [
1071
- ...(await lintTopoTargets(topoTargets, extraTopoRules, selector, true)),
1072
- ];
1119
+ return {
1120
+ diagnostics: [
1121
+ ...(await lintTopoTargets(topoTargets, extraTopoRules, selector, true)),
1122
+ ],
1123
+ sourceFiles: [],
1124
+ };
1073
1125
  }
1074
1126
 
1075
1127
  const sourceFiles = filterSourceFilesByDraftMode(
@@ -1082,7 +1134,7 @@ const lintFiles = async (
1082
1134
  topoTargets.map((target) => target.topo)
1083
1135
  );
1084
1136
  const allDiagnostics: WardenDiagnostic[] = [
1085
- ...lintSourceFiles(sourceFiles, context, selector),
1137
+ ...lintSourceFiles(sourceFiles, context, extraSourceRules, selector),
1086
1138
  ];
1087
1139
 
1088
1140
  if (
@@ -1100,7 +1152,7 @@ const lintFiles = async (
1100
1152
  );
1101
1153
  }
1102
1154
 
1103
- return allDiagnostics;
1155
+ return { diagnostics: allDiagnostics, sourceFiles };
1104
1156
  };
1105
1157
 
1106
1158
  const topoTargetsFromOptions = (
@@ -1205,6 +1257,12 @@ const checkDriftForTopoTargets = async (
1205
1257
  const shouldRunLint = (options: WardenRunOptions): boolean =>
1206
1258
  options.tier ? options.tier !== 'drift' : !options.driftOnly;
1207
1259
 
1260
+ const adapterDiagnosticsForRun = (
1261
+ rootDir: string,
1262
+ options: WardenRunOptions
1263
+ ): readonly WardenDiagnostic[] =>
1264
+ options.adapterCheck ? runWardenAdapterChecks(rootDir) : [];
1265
+
1208
1266
  const shouldRunDrift = (
1209
1267
  options: WardenRunOptions,
1210
1268
  effectiveConfig: EffectiveWardenConfig
@@ -1252,6 +1310,101 @@ const buildCliConfigLayer = (options: WardenRunOptions): WardenConfigLayer => ({
1252
1310
  : { noLockMutation: options.noLockMutation }),
1253
1311
  });
1254
1312
 
1313
+ const fixSummary = (application: WardenFixApplication): WardenFixSummary => ({
1314
+ applied: application.applied,
1315
+ filesChanged: application.filesChanged,
1316
+ skipped: application.skipped,
1317
+ });
1318
+
1319
+ const filterAppliedFixDiagnostics = (
1320
+ diagnostics: readonly WardenDiagnostic[],
1321
+ appliedDiagnostics: readonly WardenDiagnostic[]
1322
+ ): readonly WardenDiagnostic[] => {
1323
+ if (appliedDiagnostics.length === 0) {
1324
+ return diagnostics;
1325
+ }
1326
+ const applied = new Set(appliedDiagnostics);
1327
+ return diagnostics.filter((diagnostic) => !applied.has(diagnostic));
1328
+ };
1329
+
1330
+ const blockedDriftAfterSourceFixes = (): DriftResult => ({
1331
+ blockedReason:
1332
+ 'Source fixes were applied; rerun Warden to refresh drift evidence.',
1333
+ committedHash: null,
1334
+ currentHash: 'blocked',
1335
+ stale: true,
1336
+ });
1337
+
1338
+ /**
1339
+ * Apply every safe source fix among a run's diagnostics, writing patched files.
1340
+ *
1341
+ * Diagnostics with a safe, edit-bearing fix are grouped by file; each file is
1342
+ * re-read so the rule's recorded offsets stay valid, patched via
1343
+ * {@link applySafeFixesToSource}, and written back only when its source
1344
+ * actually changed. Diagnostics that carry fix metadata but are not safe with
1345
+ * edits (review-required or edit-less) are counted as skipped and left reported
1346
+ * for a human or downstream regrade to resolve. Diagnostics without fix
1347
+ * metadata — including topo diagnostics, which carry no source span — are
1348
+ * neither applied nor counted in `skipped`.
1349
+ */
1350
+ export const applySafeFixesToFiles = async (
1351
+ diagnostics: readonly WardenDiagnostic[],
1352
+ options: {
1353
+ readonly allowedFilePaths?: ReadonlySet<string> | readonly string[];
1354
+ readonly rootDir: string;
1355
+ }
1356
+ ): Promise<WardenFixApplication> => {
1357
+ const rootDir = resolve(options.rootDir);
1358
+ const allowedFilePaths =
1359
+ options.allowedFilePaths === undefined
1360
+ ? undefined
1361
+ : new Set(
1362
+ [...options.allowedFilePaths].map((filePath) => resolve(filePath))
1363
+ );
1364
+ const fixableByFile = new Map<string, WardenDiagnostic[]>();
1365
+ let skipped = 0;
1366
+ for (const diagnostic of diagnostics) {
1367
+ if (diagnostic.fix === undefined) {
1368
+ continue;
1369
+ }
1370
+ if (hasSafeFixEdits(diagnostic)) {
1371
+ const filePath = resolve(diagnostic.filePath);
1372
+ const rootRelativePath = relative(rootDir, filePath);
1373
+ const insideRoot =
1374
+ rootRelativePath.length === 0 ||
1375
+ (!rootRelativePath.startsWith('..') && !isAbsolute(rootRelativePath));
1376
+ if (
1377
+ !insideRoot ||
1378
+ (allowedFilePaths !== undefined && !allowedFilePaths.has(filePath))
1379
+ ) {
1380
+ skipped += 1;
1381
+ continue;
1382
+ }
1383
+ const bucket = fixableByFile.get(filePath) ?? [];
1384
+ bucket.push(diagnostic);
1385
+ fixableByFile.set(filePath, bucket);
1386
+ } else {
1387
+ skipped += 1;
1388
+ }
1389
+ }
1390
+
1391
+ let applied = 0;
1392
+ const appliedDiagnostics: WardenDiagnostic[] = [];
1393
+ let filesChanged = 0;
1394
+ for (const [filePath, group] of fixableByFile) {
1395
+ const source = await Bun.file(filePath).text();
1396
+ const result = applySafeFixesToSource(source, group);
1397
+ applied += result.applied.length;
1398
+ appliedDiagnostics.push(...result.applied);
1399
+ if (result.changed) {
1400
+ await Bun.write(filePath, result.patched);
1401
+ filesChanged += 1;
1402
+ }
1403
+ }
1404
+
1405
+ return { applied, appliedDiagnostics, filesChanged, skipped };
1406
+ };
1407
+
1255
1408
  /**
1256
1409
  * Run all warden checks and return a structured report.
1257
1410
  */
@@ -1280,39 +1433,64 @@ export const runWarden = async (
1280
1433
  } satisfies WardenRuleSelector;
1281
1434
  const runLint = shouldRunLint(options);
1282
1435
  const runDrift = shouldRunDrift(options, effectiveConfig);
1436
+ const lintResult = runLint
1437
+ ? await lintFiles(
1438
+ rootDir,
1439
+ effectiveConfig.drafts,
1440
+ topoTargets,
1441
+ options.extraTopoRules ?? [],
1442
+ options.extraSourceRules ?? [],
1443
+ selector
1444
+ )
1445
+ : { diagnostics: [], sourceFiles: [] };
1446
+ const adapterDiagnostics = adapterDiagnosticsForRun(rootDir, options);
1283
1447
 
1284
1448
  const rawDiagnostics = [
1285
1449
  ...configDiagnostics,
1286
1450
  ...optionDiagnostics,
1287
- ...(runLint
1288
- ? await lintFiles(
1289
- rootDir,
1290
- effectiveConfig.drafts,
1291
- topoTargets,
1292
- options.extraTopoRules ?? [],
1293
- selector
1294
- )
1295
- : []),
1451
+ ...lintResult.diagnostics,
1452
+ ...adapterDiagnostics,
1296
1453
  ];
1297
1454
  const allDiagnostics = rawDiagnostics.map(withDiagnosticGuidance);
1298
- const drift = runDrift
1299
- ? await checkDriftForTopoTargets(rootDir, topoTargets)
1300
- : null;
1455
+ const fixApplication = options.fix
1456
+ ? await applySafeFixesToFiles(allDiagnostics, {
1457
+ allowedFilePaths: lintResult.sourceFiles.map(
1458
+ (sourceFile) => sourceFile.filePath
1459
+ ),
1460
+ rootDir,
1461
+ })
1462
+ : undefined;
1463
+ const reportDiagnostics = filterAppliedFixDiagnostics(
1464
+ allDiagnostics,
1465
+ fixApplication?.appliedDiagnostics ?? []
1466
+ );
1467
+ let drift: DriftResult | null = null;
1468
+ if (runDrift) {
1469
+ drift =
1470
+ fixApplication !== undefined && fixApplication.filesChanged > 0
1471
+ ? blockedDriftAfterSourceFixes()
1472
+ : await checkDriftForTopoTargets(rootDir, topoTargets);
1473
+ }
1301
1474
 
1302
- const errorCount = allDiagnostics.filter(
1475
+ const errorCount = reportDiagnostics.filter(
1303
1476
  (d) => d.severity === 'error'
1304
1477
  ).length;
1305
- const warnCount = allDiagnostics.filter((d) => d.severity === 'warn').length;
1478
+ const warnCount = reportDiagnostics.filter(
1479
+ (d) => d.severity === 'warn'
1480
+ ).length;
1306
1481
  const topoNames =
1307
1482
  topoTargets.length > 0
1308
1483
  ? topoTargets.map((target) => target.name ?? target.topo.name)
1309
1484
  : undefined;
1310
1485
 
1311
1486
  return {
1312
- diagnostics: allDiagnostics,
1487
+ diagnostics: reportDiagnostics,
1313
1488
  drift,
1314
1489
  effectiveConfig,
1315
1490
  errorCount,
1491
+ ...(fixApplication === undefined
1492
+ ? {}
1493
+ : { fixes: fixSummary(fixApplication) }),
1316
1494
  passed: reportPassed({
1317
1495
  drift,
1318
1496
  errorCount,
@@ -1384,7 +1562,7 @@ const formatDriftSection = (drift: DriftResult | null): string[] => {
1384
1562
  return [`Drift: blocked (${drift.blockedReason})`, ''];
1385
1563
  }
1386
1564
  const label = drift.stale
1387
- ? 'Drift: trails.lock is stale (regenerate with `trails topo compile`)'
1565
+ ? 'Drift: trails.lock is stale (regenerate with `trails compile`)'
1388
1566
  : 'Drift: clean';
1389
1567
  return [label, ''];
1390
1568
  };
package/src/command.ts CHANGED
@@ -128,18 +128,22 @@ const readEnumValue = <T extends string>({
128
128
  };
129
129
 
130
130
  export interface ParsedWardenCommand {
131
+ readonly adapterCheck: boolean;
131
132
  readonly ci: boolean;
132
133
  readonly cli: WardenConfigLayer;
133
134
  readonly configPath?: string | undefined;
134
135
  readonly diagnostics: readonly WardenDiagnostic[];
136
+ readonly fix: boolean;
135
137
  readonly prePush: boolean;
136
138
  readonly rootDir?: string | undefined;
137
139
  }
138
140
 
139
141
  const createEmptyParsedCommand = (message: string): ParsedWardenCommand => ({
142
+ adapterCheck: false,
140
143
  ci: false,
141
144
  cli: {},
142
145
  diagnostics: [diagnostic({ message })],
146
+ fix: false,
143
147
  prePush: false,
144
148
  });
145
149
 
@@ -149,10 +153,12 @@ const tokenValue = (token: {
149
153
  typeof token.value === 'string' ? token.value : undefined;
150
154
 
151
155
  interface CommandParserState {
156
+ adapterCheck?: boolean | undefined;
152
157
  readonly apps: string[];
153
158
  readonly diagnostics: WardenDiagnostic[];
154
159
  readonly cli: MutableWardenConfigLayer;
155
160
  configPath?: string | undefined;
161
+ fix?: boolean | undefined;
156
162
  rootDir?: string | undefined;
157
163
  }
158
164
 
@@ -232,12 +238,14 @@ const parseTokens = (
232
238
  allowPositionals: false,
233
239
  args: [...args],
234
240
  options: {
241
+ 'adapter-check': { type: 'boolean' },
235
242
  apps: { multiple: true, short: 'a', type: 'string' },
236
243
  ci: { type: 'boolean' },
237
244
  'config-path': { type: 'string' },
238
245
  depth: { type: 'string' },
239
246
  drafts: { type: 'string' },
240
247
  'fail-on': { type: 'string' },
248
+ fix: { type: 'boolean' },
241
249
  format: { type: 'string' },
242
250
  lock: { type: 'string' },
243
251
  'no-lock-mutation': { type: 'boolean' },
@@ -384,10 +392,18 @@ const applyCommandOption = (
384
392
  state.apps.push(...splitApps(value));
385
393
  return;
386
394
  }
395
+ if (token.name === 'adapter-check') {
396
+ state.adapterCheck = true;
397
+ return;
398
+ }
387
399
  if (token.name === 'config-path') {
388
400
  state.configPath = value;
389
401
  return;
390
402
  }
403
+ if (token.name === 'fix') {
404
+ state.fix = true;
405
+ return;
406
+ }
391
407
  if (token.name === 'no-lock-mutation') {
392
408
  state.cli.noLockMutation = true;
393
409
  return;
@@ -437,12 +453,14 @@ export const parseWardenCommandArgs = (
437
453
  }
438
454
 
439
455
  return {
456
+ adapterCheck: state.adapterCheck ?? false,
440
457
  ci,
441
458
  cli: cleanUndefined(
442
459
  state.cli as Record<string, unknown>
443
460
  ) as WardenConfigLayer,
444
461
  configPath: state.configPath,
445
462
  diagnostics: state.diagnostics,
463
+ fix: state.fix ?? false,
446
464
  prePush,
447
465
  rootDir: state.rootDir,
448
466
  };
@@ -788,24 +806,30 @@ const effectiveConfigNeedsTopo = (depth: WardenDepth): boolean =>
788
806
  depth === 'topo' || depth === 'all';
789
807
 
790
808
  const buildRunOptions = ({
809
+ adapterCheck,
791
810
  cli,
792
811
  config,
793
812
  env,
813
+ fix,
794
814
  rootDir,
795
815
  topos,
796
816
  }: {
817
+ readonly adapterCheck: boolean;
797
818
  readonly cli: WardenConfigLayer;
798
819
  readonly config?: WardenConfigInput | undefined;
799
820
  readonly env: EnvRecord;
821
+ readonly fix: boolean;
800
822
  readonly rootDir: string;
801
823
  readonly topos: readonly WardenTopoTarget[];
802
824
  }): WardenRunOptions => ({
803
825
  ...cleanUndefined({
826
+ adapterCheck,
804
827
  apps: cli.apps,
805
828
  config,
806
829
  depth: cli.depth,
807
830
  drafts: cli.drafts,
808
831
  failOn: cli.failOn,
832
+ fix,
809
833
  format: cli.format,
810
834
  lock: cli.lock,
811
835
  noLockMutation: cli.noLockMutation,
@@ -904,9 +928,11 @@ export const runWardenCommand = async ({
904
928
  : { diagnostics: [], topos: [] };
905
929
  const report = await runWarden(
906
930
  buildRunOptions({
931
+ adapterCheck: parsed.adapterCheck,
907
932
  cli: parsed.cli,
908
933
  config: loadedConfig.config,
909
934
  env,
935
+ fix: parsed.fix,
910
936
  rootDir,
911
937
  topos: topoResolution.topos,
912
938
  })
package/src/drift.ts CHANGED
@@ -95,7 +95,7 @@ export const checkDrift = async (
95
95
  ) ?? null;
96
96
  if (lockManifest !== null && topoArtifact === null) {
97
97
  return blockedDrift(
98
- 'trails.lock does not contain a topo.lock artifact. Regenerate with `trails topo compile`.'
98
+ 'trails.lock does not contain a topo.lock artifact. Regenerate with `trails compile`.'
99
99
  );
100
100
  }
101
101
  const readStoredHash = (): string | undefined => {