@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.
- package/CHANGELOG.md +79 -0
- package/README.md +12 -30
- package/bin/warden.ts +29 -1
- package/package.json +9 -8
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +238 -60
- package/src/command.ts +26 -0
- package/src/drift.ts +1 -1
- package/src/fix.ts +120 -0
- package/src/formatters.ts +14 -2
- package/src/guide.ts +11 -0
- package/src/index.ts +31 -1
- package/src/rules/ast.ts +84 -25
- package/src/rules/circular-refs.ts +1 -1
- package/src/rules/{cross-declarations.ts → composes-declarations.ts} +198 -89
- package/src/rules/context-no-surface-types.ts +4 -4
- package/src/rules/contour-exists.ts +1 -1
- package/src/rules/dead-internal-trail.ts +22 -9
- package/src/rules/fires-declarations.ts +3 -3
- package/src/rules/implementation-returns-result.ts +269 -76
- package/src/rules/index.ts +51 -3
- package/src/rules/intent-propagation.ts +6 -6
- package/src/rules/metadata.ts +117 -12
- package/src/rules/missing-visibility.ts +14 -14
- package/src/rules/no-destructured-compose.ts +192 -0
- package/src/rules/no-direct-implementation-call.ts +2 -2
- package/src/rules/no-legacy-layer-imports.ts +19 -1
- package/src/rules/no-redundant-result-error-wrap.ts +331 -0
- package/src/rules/no-sync-result-assumption.ts +2 -2
- package/src/rules/no-throw-in-implementation.ts +2 -3
- package/src/rules/no-top-level-surface.ts +389 -0
- package/src/rules/on-references-exist.ts +1 -1
- package/src/rules/reference-exists.ts +1 -1
- package/src/rules/registry-names.ts +28 -2
- package/src/rules/resolved-import-boundary.ts +2 -2
- package/src/rules/resource-declarations.ts +4 -4
- package/src/rules/resource-exists.ts +1 -1
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +39 -0
- package/src/rules/trail-versioning-source.ts +1094 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +87 -5
- package/src/rules/valid-detour-contract.ts +1 -1
- package/src/rules/warden-export-symmetry.ts +1 -1
- package/src/rules/warden-rules-use-ast.ts +2 -2
- package/src/trails/activation-orphan.trail.ts +4 -1
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/dead-internal-trail.trail.ts +4 -4
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
- package/src/trails/index.ts +12 -1
- package/src/trails/intent-propagation.trail.ts +3 -3
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-visibility.trail.ts +2 -2
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-direct-implementation-call.trail.ts +2 -2
- package/src/trails/no-legacy-layer-imports.trail.ts +6 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/public-internal-deep-imports.trail.ts +1 -1
- package/src/trails/resolved-import-boundary.trail.ts +4 -4
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +2 -2
- package/src/trails/schema.ts +32 -6
- package/src/trails/signal-graph-coaching.trail.ts +4 -1
- package/src/trails/unmaterialized-activation-source.trail.ts +4 -1
- package/src/trails/valid-detour-contract.trail.ts +1 -1
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/wrap-rule.ts +5 -3
- 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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
453
|
+
const collectComposedTrailIds = (
|
|
427
454
|
sourceCode: string,
|
|
428
455
|
filePath: string,
|
|
429
|
-
|
|
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
|
|
436
|
-
|
|
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
|
|
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
|
|
629
|
-
context.
|
|
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
|
-
|
|
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
|
-
|
|
713
|
+
collectComposedTrailIds(
|
|
687
714
|
sourceFile.sourceCode,
|
|
688
715
|
sourceFile.filePath,
|
|
689
|
-
context.
|
|
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(
|
|
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
|
|
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<
|
|
1117
|
+
): Promise<WardenLintResult> => {
|
|
1069
1118
|
if (selector.tier === 'topo-aware') {
|
|
1070
|
-
return
|
|
1071
|
-
|
|
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
|
-
...
|
|
1288
|
-
|
|
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
|
|
1299
|
-
? await
|
|
1300
|
-
|
|
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 =
|
|
1475
|
+
const errorCount = reportDiagnostics.filter(
|
|
1303
1476
|
(d) => d.severity === 'error'
|
|
1304
1477
|
).length;
|
|
1305
|
-
const warnCount =
|
|
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:
|
|
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
|
|
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
|
|
98
|
+
'trails.lock does not contain a topo.lock artifact. Regenerate with `trails compile`.'
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
const readStoredHash = (): string | undefined => {
|