@ontrails/warden 1.0.0-beta.5 → 1.0.0-beta.50
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 +929 -15
- package/README.md +133 -27
- package/bin/warden.ts +51 -0
- package/package.json +32 -6
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +1690 -114
- package/src/command.ts +986 -0
- package/src/config.ts +193 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +233 -23
- package/src/fix.ts +126 -0
- package/src/formatters.ts +78 -13
- package/src/guide.ts +245 -0
- package/src/index.ts +260 -15
- package/src/project-context.ts +446 -0
- package/src/project-rules.ts +290 -0
- package/src/regrade-history.ts +188 -0
- package/src/regrade-receipt.ts +484 -0
- package/src/resolve.ts +531 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/captured-kernel.ts +375 -0
- package/src/rules/circular-refs.ts +150 -0
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +839 -0
- package/src/rules/context-no-surface-types.ts +79 -15
- package/src/rules/dead-internal-trail.ts +161 -0
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/draft-file-marking.ts +155 -0
- package/src/rules/draft-visible-debt.ts +82 -0
- package/src/rules/duplicate-exported-symbol.ts +172 -0
- package/src/rules/duplicate-public-contract.ts +137 -0
- package/src/rules/entity-exists.ts +254 -0
- package/src/rules/entity-ids.ts +15 -0
- package/src/rules/error-mapping-completeness.ts +290 -0
- package/src/rules/example-valid.ts +395 -0
- package/src/rules/fires-declarations.ts +740 -0
- package/src/rules/governed-symbol-residue.ts +535 -0
- package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
- package/src/rules/implementation-returns-result.ts +1496 -163
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +583 -0
- package/src/rules/index.ts +288 -13
- package/src/rules/intent-propagation.ts +125 -0
- package/src/rules/layer-field-name-drift.ts +102 -0
- package/src/rules/library-render-coherence.ts +97 -0
- package/src/rules/metadata.ts +908 -0
- package/src/rules/missing-reconcile.ts +97 -0
- package/src/rules/missing-visibility.ts +111 -0
- package/src/rules/no-destructured-compose.ts +196 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +12 -7
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +118 -0
- package/src/rules/no-redundant-result-error-wrap.ts +382 -0
- package/src/rules/no-retired-cross-vocabulary.ts +203 -0
- package/src/rules/no-sync-result-assumption.ts +1141 -98
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +15 -8
- package/src/rules/no-top-level-surface.ts +371 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +149 -0
- package/src/rules/owner-render-parity.ts +143 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/public-export-example-coverage.ts +561 -0
- package/src/rules/public-internal-deep-imports.ts +454 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +188 -0
- package/src/rules/reference-exists.ts +97 -0
- package/src/rules/registry-names.ts +169 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +697 -0
- package/src/rules/resource-exists.ts +181 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/retired-vocabulary.ts +1407 -0
- package/src/rules/scan.ts +38 -25
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +220 -0
- package/src/rules/source/composition.ts +165 -0
- package/src/rules/source/drafts.ts +164 -0
- package/src/rules/source/entities.ts +618 -0
- package/src/rules/source/pragmas.ts +45 -0
- package/src/rules/source/resources.ts +64 -0
- package/src/rules/source/signals.ts +397 -0
- package/src/rules/source/stores.ts +310 -0
- package/src/rules/specs.ts +9 -5
- package/src/rules/static-resource-accessor-preference.ts +653 -0
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/surface-trailhead-coherence.ts +366 -0
- package/src/rules/trail-fork-coaching.ts +625 -0
- package/src/rules/trail-versioning-source.ts +1076 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +403 -8
- package/src/rules/unmaterialized-activation-source.ts +85 -0
- package/src/rules/unreachable-detour-shadowing.ts +339 -0
- package/src/rules/valid-describe-refs.ts +162 -32
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +540 -0
- package/src/rules/warden-rules-use-ast.ts +1109 -0
- package/src/rules/webhook-route-collision.ts +306 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/captured-kernel.trail.ts +108 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/entity-exists.trail.ts +21 -0
- package/src/trails/error-mapping-completeness.trail.ts +30 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +90 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/library-render-coherence.trail.ts +43 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-render-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +202 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +281 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- 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/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +64 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/wrap-rule.ts +266 -0
- package/src/workspaces.ts +264 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/cli.d.ts +0 -46
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -218
- package/dist/cli.js.map +0 -1
- package/dist/drift.d.ts +0 -26
- package/dist/drift.d.ts.map +0 -1
- package/dist/drift.js +0 -27
- package/dist/drift.js.map +0 -1
- package/dist/formatters.d.ts +0 -29
- package/dist/formatters.d.ts.map +0 -1
- package/dist/formatters.js +0 -87
- package/dist/formatters.js.map +0 -1
- package/dist/index.d.ts +0 -26
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -26
- package/dist/index.js.map +0 -1
- package/dist/rules/ast.d.ts +0 -41
- package/dist/rules/ast.d.ts.map +0 -1
- package/dist/rules/ast.js +0 -161
- package/dist/rules/ast.js.map +0 -1
- package/dist/rules/context-no-surface-types.d.ts +0 -12
- package/dist/rules/context-no-surface-types.d.ts.map +0 -1
- package/dist/rules/context-no-surface-types.js +0 -96
- package/dist/rules/context-no-surface-types.js.map +0 -1
- package/dist/rules/follow-declarations.d.ts +0 -13
- package/dist/rules/follow-declarations.d.ts.map +0 -1
- package/dist/rules/follow-declarations.js +0 -195
- package/dist/rules/follow-declarations.js.map +0 -1
- package/dist/rules/implementation-returns-result.d.ts +0 -13
- package/dist/rules/implementation-returns-result.d.ts.map +0 -1
- package/dist/rules/implementation-returns-result.js +0 -277
- package/dist/rules/implementation-returns-result.js.map +0 -1
- package/dist/rules/index.d.ts +0 -16
- package/dist/rules/index.d.ts.map +0 -1
- package/dist/rules/index.js +0 -37
- package/dist/rules/index.js.map +0 -1
- package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
- package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
- package/dist/rules/no-direct-impl-in-route.js +0 -47
- package/dist/rules/no-direct-impl-in-route.js.map +0 -1
- package/dist/rules/no-direct-implementation-call.d.ts +0 -12
- package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
- package/dist/rules/no-direct-implementation-call.js +0 -39
- package/dist/rules/no-direct-implementation-call.js.map +0 -1
- package/dist/rules/no-sync-result-assumption.d.ts +0 -6
- package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
- package/dist/rules/no-sync-result-assumption.js +0 -98
- package/dist/rules/no-sync-result-assumption.js.map +0 -1
- package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
- package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
- package/dist/rules/no-throw-in-detour-target.js +0 -87
- package/dist/rules/no-throw-in-detour-target.js.map +0 -1
- package/dist/rules/no-throw-in-implementation.d.ts +0 -9
- package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
- package/dist/rules/no-throw-in-implementation.js +0 -34
- package/dist/rules/no-throw-in-implementation.js.map +0 -1
- package/dist/rules/prefer-schema-inference.d.ts +0 -7
- package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
- package/dist/rules/prefer-schema-inference.js +0 -86
- package/dist/rules/prefer-schema-inference.js.map +0 -1
- package/dist/rules/scan.d.ts +0 -8
- package/dist/rules/scan.d.ts.map +0 -1
- package/dist/rules/scan.js +0 -32
- package/dist/rules/scan.js.map +0 -1
- package/dist/rules/specs.d.ts +0 -29
- package/dist/rules/specs.d.ts.map +0 -1
- package/dist/rules/specs.js +0 -192
- package/dist/rules/specs.js.map +0 -1
- package/dist/rules/structure.d.ts +0 -13
- package/dist/rules/structure.d.ts.map +0 -1
- package/dist/rules/structure.js +0 -142
- package/dist/rules/structure.js.map +0 -1
- package/dist/rules/types.d.ts +0 -52
- package/dist/rules/types.d.ts.map +0 -1
- package/dist/rules/types.js +0 -2
- package/dist/rules/types.js.map +0 -1
- package/dist/rules/valid-describe-refs.d.ts +0 -7
- package/dist/rules/valid-describe-refs.d.ts.map +0 -1
- package/dist/rules/valid-describe-refs.js +0 -51
- package/dist/rules/valid-describe-refs.js.map +0 -1
- package/dist/rules/valid-detour-refs.d.ts +0 -6
- package/dist/rules/valid-detour-refs.d.ts.map +0 -1
- package/dist/rules/valid-detour-refs.js +0 -116
- package/dist/rules/valid-detour-refs.js.map +0 -1
- package/src/__tests__/cli.test.ts +0 -198
- package/src/__tests__/drift.test.ts +0 -74
- package/src/__tests__/follow-declarations.test.ts +0 -151
- package/src/__tests__/formatters.test.ts +0 -157
- package/src/__tests__/implementation-returns-result.test.ts +0 -129
- package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
- package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
- package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
- package/src/__tests__/prefer-schema-inference.test.ts +0 -84
- package/src/__tests__/rules.test.ts +0 -227
- package/src/__tests__/valid-describe-refs.test.ts +0 -60
- package/src/rules/ast.ts +0 -213
- package/src/rules/follow-declarations.ts +0 -283
- package/src/rules/no-direct-impl-in-route.ts +0 -81
- package/src/rules/no-throw-in-detour-target.ts +0 -150
- package/src/rules/valid-detour-refs.ts +0 -187
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/cli.ts
CHANGED
|
@@ -5,38 +5,215 @@
|
|
|
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
|
-
import
|
|
10
|
+
import { resolveTrailsProjectRoot } from '@ontrails/config';
|
|
11
|
+
import {
|
|
12
|
+
getEntityReferences,
|
|
13
|
+
matchesAnyPathGlob,
|
|
14
|
+
resolveSurfaceOverlayBindings,
|
|
15
|
+
surfaceBindingsFromLockOverlays,
|
|
16
|
+
} from '@ontrails/core';
|
|
17
|
+
import type { SurfaceBindings, Topo } from '@ontrails/core';
|
|
18
|
+
import { deriveTopoGraph } from '@ontrails/topography';
|
|
19
|
+
import type {
|
|
20
|
+
TopoGraph,
|
|
21
|
+
TopoGraphOverlayRegistration,
|
|
22
|
+
} from '@ontrails/topography';
|
|
11
23
|
|
|
24
|
+
import type {
|
|
25
|
+
EffectiveWardenConfig,
|
|
26
|
+
WardenConfigInput,
|
|
27
|
+
WardenConfigLayer,
|
|
28
|
+
WardenDepth,
|
|
29
|
+
WardenFailOn,
|
|
30
|
+
WardenFormat,
|
|
31
|
+
WardenScope,
|
|
32
|
+
WardenLockMode,
|
|
33
|
+
} from './config.js';
|
|
34
|
+
import { runWardenAdapterChecks } from './adapter-check.js';
|
|
35
|
+
import { resolveWardenConfig } from './config.js';
|
|
36
|
+
import { isDraftMarkedFile } from './draft.js';
|
|
37
|
+
import { applySafeFixesToSource, hasSafeFixEdits } from './fix.js';
|
|
12
38
|
import type { DriftResult } from './drift.js';
|
|
13
|
-
import { checkDrift } from './drift.js';
|
|
39
|
+
import { checkDrift, staleDriftMessage } from './drift.js';
|
|
40
|
+
import { loadProjectWardenRules } from './project-rules.js';
|
|
41
|
+
import type { ProjectWardenRules } from './project-rules.js';
|
|
42
|
+
import { loadGovernedVocabularyHistory } from './regrade-history.js';
|
|
43
|
+
import {
|
|
44
|
+
collectProjectDocumentationImportResolutions,
|
|
45
|
+
collectProjectExportedSymbolDefinitions,
|
|
46
|
+
collectProjectImportResolutions,
|
|
47
|
+
collectPublicWorkspaces,
|
|
48
|
+
} from './project-context.js';
|
|
49
|
+
import {
|
|
50
|
+
collectComposeTargetTrailIds,
|
|
51
|
+
collectTrailIntentsById,
|
|
52
|
+
} from './rules/source/composition.js';
|
|
53
|
+
import {
|
|
54
|
+
collectEntityDefinitionIds,
|
|
55
|
+
collectEntityReferenceTargetsByName,
|
|
56
|
+
} from './rules/source/entities.js';
|
|
57
|
+
import { collectResourceDefinitionIds } from './rules/source/resources.js';
|
|
58
|
+
import {
|
|
59
|
+
collectOnTargetSignalIds as collectOnTargetSignalIdsFromAst,
|
|
60
|
+
collectSignalDefinitionIds,
|
|
61
|
+
} from './rules/source/signals.js';
|
|
14
62
|
import {
|
|
15
|
-
|
|
63
|
+
collectCrudTableIds as collectCrudTableIdsFromAst,
|
|
64
|
+
collectReconcileTableIds as collectReconcileTableIdsFromAst,
|
|
65
|
+
} from './rules/source/stores.js';
|
|
66
|
+
import {
|
|
67
|
+
collectSourceTree,
|
|
16
68
|
findTrailDefinitions,
|
|
17
69
|
parse,
|
|
18
|
-
|
|
19
|
-
} from '
|
|
20
|
-
import {
|
|
70
|
+
} from '@ontrails/source';
|
|
71
|
+
import type { SourceTreeCollection } from '@ontrails/source';
|
|
72
|
+
import { collectFileCrudCoverage } from './rules/incomplete-crud.js';
|
|
73
|
+
import { wardenRules, wardenTopoRules } from './rules/index.js';
|
|
74
|
+
import { getWardenRuleMetadata } from './rules/metadata.js';
|
|
75
|
+
import {
|
|
76
|
+
isWardenDevPermitTestScanTarget,
|
|
77
|
+
isWardenSourceScanTarget,
|
|
78
|
+
} from './rules/scan.js';
|
|
21
79
|
import type {
|
|
80
|
+
AuthoredMcpSurfaceBindingSet,
|
|
22
81
|
ProjectAwareWardenRule,
|
|
23
82
|
ProjectContext,
|
|
83
|
+
TopoAwareWardenRule,
|
|
24
84
|
WardenDiagnostic,
|
|
85
|
+
WardenExportedSymbolDefinition,
|
|
86
|
+
WardenGuidanceLink,
|
|
25
87
|
WardenRule,
|
|
88
|
+
WardenRuleTier,
|
|
26
89
|
} from './rules/types.js';
|
|
90
|
+
import type { WardenImportResolution } from './resolve.js';
|
|
27
91
|
|
|
28
92
|
/**
|
|
29
|
-
*
|
|
93
|
+
* Resolved topo input for Warden runs that govern multiple apps.
|
|
30
94
|
*/
|
|
31
|
-
export interface
|
|
95
|
+
export interface WardenTopoTarget {
|
|
96
|
+
/** Optional precomputed topo graph, including graph-only audit annotations. */
|
|
97
|
+
readonly graph?: TopoGraph | undefined;
|
|
98
|
+
/** Stable app/topo label used to tag topo-aware diagnostics. */
|
|
99
|
+
readonly name?: string | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* App-module overlay registrations collected through the shared
|
|
102
|
+
* `resolveTrailsOverlays` channel, so fresh drift and topo-aware rule
|
|
103
|
+
* derivations carry the same overlays the committed lock embeds.
|
|
104
|
+
*/
|
|
105
|
+
readonly overlays?: readonly TopoGraphOverlayRegistration[] | undefined;
|
|
106
|
+
/** Resolved topo module to inspect. */
|
|
107
|
+
readonly topo: Topo;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Options for the shared Warden runner.
|
|
112
|
+
*/
|
|
113
|
+
export interface WardenRunOptions {
|
|
32
114
|
/** Root directory to scan for TypeScript files. Defaults to cwd. */
|
|
33
115
|
readonly rootDir?: string | undefined;
|
|
116
|
+
/** Warden config section from `trails.config.ts`, if already loaded. */
|
|
117
|
+
readonly config?: WardenConfigInput | undefined;
|
|
118
|
+
/** CLI/config-layer app names carried through shared resolution. */
|
|
119
|
+
readonly apps?: readonly string[] | undefined;
|
|
120
|
+
/** Include shared adapter authoring checks as Warden diagnostics. */
|
|
121
|
+
readonly adapterCheck?: boolean | undefined;
|
|
122
|
+
/** Cumulative analysis depth for the final M1 surfaces. */
|
|
123
|
+
readonly depth?: WardenDepth | undefined;
|
|
124
|
+
/** Draft-state handling mode for final M1 surfaces. */
|
|
125
|
+
readonly drafts?: EffectiveWardenConfig['drafts'] | undefined;
|
|
126
|
+
/** Failure threshold used to compute `report.passed`. */
|
|
127
|
+
readonly failOn?: WardenFailOn | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Apply safe source fixes among the run's diagnostics, writing changed files.
|
|
130
|
+
*
|
|
131
|
+
* Only `safety: 'safe'` fixes with concrete edits are applied; review-required,
|
|
132
|
+
* edit-less, and topo diagnostics stay reported but unapplied.
|
|
133
|
+
*/
|
|
134
|
+
readonly fix?: boolean | undefined;
|
|
135
|
+
/** Output format requested by the caller. */
|
|
136
|
+
readonly format?: WardenFormat | undefined;
|
|
137
|
+
/** Root-relative source paths that Warden should not govern. */
|
|
138
|
+
readonly scope?: WardenScope | undefined;
|
|
139
|
+
/** Lockfile mode requested by the caller. */
|
|
140
|
+
readonly lock?: WardenLockMode | undefined;
|
|
141
|
+
/** Suppress lockfile mutation for CI/pre-push callers. */
|
|
142
|
+
readonly noLockMutation?: boolean | undefined;
|
|
143
|
+
/** Environment layer for config resolution. Pass `process.env` at process boundaries. */
|
|
144
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
34
145
|
/** Only run lint rules, skip drift detection */
|
|
35
146
|
readonly lintOnly?: boolean | undefined;
|
|
36
147
|
/** Only run drift detection, skip lint rules */
|
|
37
148
|
readonly driftOnly?: boolean | undefined;
|
|
38
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Run a single Warden tier. Defaults to all lint tiers plus drift.
|
|
151
|
+
*
|
|
152
|
+
* Selecting a non-drift tier skips drift detection; selecting `drift` skips
|
|
153
|
+
* lint rule dispatch. `lintOnly` and `driftOnly` remain compatibility shims.
|
|
154
|
+
*/
|
|
155
|
+
readonly tier?: WardenRuleTier | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Load project-local Warden rules from `.trails/rules.ts` or `.trails/rules/`.
|
|
158
|
+
*
|
|
159
|
+
* Enabled by default for normal runs so a Trails app can carry migration or
|
|
160
|
+
* repo-local governance with the project instead of shipping it from
|
|
161
|
+
* `@ontrails/warden`. Set to `false` for narrow tests or embedders that need
|
|
162
|
+
* only built-in and explicitly provided rules.
|
|
163
|
+
*/
|
|
164
|
+
readonly projectRules?: boolean | undefined;
|
|
165
|
+
/**
|
|
166
|
+
* App topology for drift detection. When provided, enables real topology
|
|
167
|
+
* drift comparison and unlocks the topo-aware rule dispatch path.
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* Topo-aware rules (both built-in `wardenTopoRules` and `extraTopoRules`)
|
|
171
|
+
* only fire when a `Topo` is supplied. Runs without a topo silently skip
|
|
172
|
+
* topo-aware dispatch — callers that depend on a topo-aware rule firing
|
|
173
|
+
* must pass `topo` explicitly.
|
|
174
|
+
*/
|
|
39
175
|
readonly topo?: Topo | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* Multiple resolved topos to govern in one invocation.
|
|
178
|
+
*
|
|
179
|
+
* Source/project rules run once; topo-aware rules run once per target.
|
|
180
|
+
*/
|
|
181
|
+
readonly topos?: readonly WardenTopoTarget[] | undefined;
|
|
182
|
+
/**
|
|
183
|
+
* Extra topo-aware rules to run in addition to the built-in registry.
|
|
184
|
+
*
|
|
185
|
+
* Primarily a test hook — production callers should register rules via
|
|
186
|
+
* `wardenTopoRules` in `rules/index.ts`. These rules are only invoked
|
|
187
|
+
* when `topo` is also supplied (see `topo` remarks).
|
|
188
|
+
*/
|
|
189
|
+
readonly extraTopoRules?: readonly TopoAwareWardenRule[] | undefined;
|
|
190
|
+
/**
|
|
191
|
+
* Extra source rules to run in addition to the built-in registry.
|
|
192
|
+
*
|
|
193
|
+
* Primarily a test hook — production callers should register durable rules
|
|
194
|
+
* via `wardenRules` in `rules/index.ts`.
|
|
195
|
+
*/
|
|
196
|
+
readonly extraSourceRules?: readonly WardenRule[] | undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Backwards-compatible name for older consumers. */
|
|
200
|
+
export type WardenOptions = WardenRunOptions;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Aggregate outcome of a `--fix` pass over a run's diagnostics.
|
|
204
|
+
*/
|
|
205
|
+
export interface WardenFixSummary {
|
|
206
|
+
/** Diagnostics whose safe fix was applied to source. */
|
|
207
|
+
readonly applied: number;
|
|
208
|
+
/** Source files rewritten with patched content. */
|
|
209
|
+
readonly filesChanged: number;
|
|
210
|
+
/** Diagnostics carrying fix metadata left unapplied (review or edit-less). */
|
|
211
|
+
readonly skipped: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface WardenFixApplication extends WardenFixSummary {
|
|
215
|
+
/** Diagnostics removed from the final report because their safe fix applied. */
|
|
216
|
+
readonly appliedDiagnostics: readonly WardenDiagnostic[];
|
|
40
217
|
}
|
|
41
218
|
|
|
42
219
|
/**
|
|
@@ -53,34 +230,161 @@ export interface WardenReport {
|
|
|
53
230
|
readonly drift: DriftResult | null;
|
|
54
231
|
/** Whether the warden run passed (no errors, no drift) */
|
|
55
232
|
readonly passed: boolean;
|
|
233
|
+
/** Effective shared config consumed by this run. */
|
|
234
|
+
readonly effectiveConfig?: EffectiveWardenConfig | undefined;
|
|
235
|
+
/** Resolved topo/app labels governed by this run. */
|
|
236
|
+
readonly topoNames?: readonly string[] | undefined;
|
|
237
|
+
/** Safe-fix application summary, present only when a `--fix` pass ran. */
|
|
238
|
+
readonly fixes?: WardenFixSummary | undefined;
|
|
56
239
|
}
|
|
57
240
|
|
|
58
241
|
/**
|
|
59
|
-
* Collect
|
|
242
|
+
* Collect Warden scan targets under a directory, excluding generated and test
|
|
243
|
+
* surfaces that should not contribute most committed-source diagnostics.
|
|
60
244
|
*/
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
245
|
+
const collectFilesMatching = (
|
|
246
|
+
collection: NonNullable<ReturnType<typeof collectSourceTree>>,
|
|
247
|
+
pattern: string,
|
|
248
|
+
dot = false
|
|
249
|
+
): readonly string[] => {
|
|
250
|
+
const glob = new Bun.Glob(pattern);
|
|
251
|
+
const files: string[] = [];
|
|
252
|
+
for (const file of collection.files) {
|
|
253
|
+
if (
|
|
254
|
+
(dot ||
|
|
255
|
+
!file.path.split('/').some((segment) => segment.startsWith('.'))) &&
|
|
256
|
+
glob.match(file.path) &&
|
|
257
|
+
isWardenSourceScanTarget(file.path)
|
|
258
|
+
) {
|
|
259
|
+
files.push(file.absolutePath);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return files;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const collectTsFiles = (
|
|
266
|
+
collection: NonNullable<ReturnType<typeof collectSourceTree>>
|
|
267
|
+
): readonly string[] => collectFilesMatching(collection, '**/*.ts');
|
|
268
|
+
|
|
269
|
+
const draftModeIncludesFile = (
|
|
270
|
+
filePath: string,
|
|
271
|
+
drafts: EffectiveWardenConfig['drafts']
|
|
272
|
+
): boolean => {
|
|
273
|
+
const isDraftFile = isDraftMarkedFile(filePath);
|
|
274
|
+
if (drafts === 'exclude') {
|
|
275
|
+
return !isDraftFile;
|
|
276
|
+
}
|
|
277
|
+
if (drafts === 'only') {
|
|
278
|
+
return isDraftFile;
|
|
279
|
+
}
|
|
280
|
+
return true;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const filterSourceFilesByDraftMode = (
|
|
284
|
+
sourceFiles: readonly SourceFile[],
|
|
285
|
+
drafts: EffectiveWardenConfig['drafts']
|
|
286
|
+
): readonly SourceFile[] =>
|
|
287
|
+
drafts === 'include'
|
|
288
|
+
? sourceFiles
|
|
289
|
+
: sourceFiles.filter((sourceFile) =>
|
|
290
|
+
draftModeIncludesFile(sourceFile.filePath, drafts)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const rootRelativeScopePath = (rootDir: string, filePath: string): string =>
|
|
294
|
+
relative(rootDir, filePath).replaceAll('\\', '/');
|
|
295
|
+
|
|
296
|
+
const filterSourceFilesByScope = (
|
|
297
|
+
sourceFiles: readonly SourceFile[],
|
|
298
|
+
rootDir: string,
|
|
299
|
+
scope: WardenScope
|
|
300
|
+
): readonly SourceFile[] => {
|
|
301
|
+
if (scope.exclude.length === 0) {
|
|
302
|
+
return sourceFiles;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return sourceFiles.filter(
|
|
306
|
+
(sourceFile) =>
|
|
307
|
+
!matchesAnyPathGlob(
|
|
308
|
+
rootRelativeScopePath(rootDir, sourceFile.filePath),
|
|
309
|
+
scope.exclude
|
|
310
|
+
)
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const EMPTY_WARDEN_SCOPE: WardenScope = { exclude: [] };
|
|
315
|
+
|
|
316
|
+
const collectDevPermitTestFiles = (
|
|
317
|
+
collection: NonNullable<ReturnType<typeof collectSourceTree>>
|
|
318
|
+
): readonly string[] => {
|
|
72
319
|
const glob = new Bun.Glob('**/*.ts');
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
320
|
+
const files: string[] = [];
|
|
321
|
+
for (const file of collection.files) {
|
|
322
|
+
if (
|
|
323
|
+
!file.path.split('/').some((segment) => segment.startsWith('.')) &&
|
|
324
|
+
glob.match(file.path) &&
|
|
325
|
+
isWardenDevPermitTestScanTarget(file.path)
|
|
326
|
+
) {
|
|
327
|
+
files.push(file.absolutePath);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return files;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const collectTextScanFiles = (
|
|
334
|
+
collection: NonNullable<ReturnType<typeof collectSourceTree>>
|
|
335
|
+
): readonly string[] => [
|
|
336
|
+
...collectFilesMatching(collection, '**/*.sh', true),
|
|
337
|
+
...collectFilesMatching(collection, '**/*.bash', true),
|
|
338
|
+
...collectFilesMatching(collection, '**/*.zsh', true),
|
|
339
|
+
...collectFilesMatching(collection, '**/*.yml', true),
|
|
340
|
+
...collectFilesMatching(collection, '**/*.yaml', true),
|
|
341
|
+
...collectFilesMatching(collection, 'plugin/**/*.json', true),
|
|
342
|
+
...collectFilesMatching(collection, 'plugin/**/*.jsonc', true),
|
|
343
|
+
...collectFilesMatching(collection, 'plugin/**/*.toml', true),
|
|
344
|
+
...collectFilesMatching(collection, 'plugin/**/*.cjs', true),
|
|
345
|
+
...collectFilesMatching(collection, 'plugin/**/*.mjs', true),
|
|
346
|
+
...collectFilesMatching(collection, 'scripts/**/*.json', true),
|
|
347
|
+
...collectFilesMatching(collection, 'scripts/**/*.jsonc', true),
|
|
348
|
+
...collectFilesMatching(collection, 'scripts/**/*.toml', true),
|
|
349
|
+
...collectFilesMatching(collection, 'scripts/**/*.cjs', true),
|
|
350
|
+
...collectFilesMatching(collection, 'scripts/**/*.mjs', true),
|
|
351
|
+
...collectFilesMatching(collection, '**/package.json', true),
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
const isDocumentationScanTarget = (match: string): boolean => {
|
|
355
|
+
if (
|
|
356
|
+
match === 'AGENTS.md' ||
|
|
357
|
+
match === 'README.md' ||
|
|
358
|
+
/^(?:packages|adapters|apps)(?:\/[^/]+)*\/AGENTS\.md$/.test(match)
|
|
359
|
+
) {
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
if (/^(?:packages|adapters|apps)\/[^/]+\/README\.md$/.test(match)) {
|
|
363
|
+
return true;
|
|
78
364
|
}
|
|
365
|
+
return (
|
|
366
|
+
(match.startsWith('plugin/') ||
|
|
367
|
+
(match.startsWith('docs/') &&
|
|
368
|
+
!match.startsWith('docs/adr/') &&
|
|
369
|
+
!match.startsWith('docs/migration/') &&
|
|
370
|
+
!match.startsWith('docs/releases/'))) &&
|
|
371
|
+
match.endsWith('.md')
|
|
372
|
+
);
|
|
373
|
+
};
|
|
79
374
|
|
|
375
|
+
const collectDocumentationFiles = (
|
|
376
|
+
collection: NonNullable<ReturnType<typeof collectSourceTree>>
|
|
377
|
+
): readonly string[] => {
|
|
378
|
+
const glob = new Bun.Glob('**/*.md');
|
|
80
379
|
const files: string[] = [];
|
|
81
|
-
for (const
|
|
82
|
-
if (
|
|
83
|
-
|
|
380
|
+
for (const file of collection.files) {
|
|
381
|
+
if (
|
|
382
|
+
!file.path.split('/').some((segment) => segment.startsWith('.')) &&
|
|
383
|
+
glob.match(file.path) &&
|
|
384
|
+
isWardenSourceScanTarget(file.path) &&
|
|
385
|
+
isDocumentationScanTarget(file.path)
|
|
386
|
+
) {
|
|
387
|
+
files.push(file.absolutePath);
|
|
84
388
|
}
|
|
85
389
|
}
|
|
86
390
|
return files;
|
|
@@ -88,9 +392,178 @@ const collectTsFiles = (dir: string): readonly string[] => {
|
|
|
88
392
|
|
|
89
393
|
interface SourceFile {
|
|
90
394
|
readonly filePath: string;
|
|
395
|
+
readonly kind: 'documentation' | 'text' | 'typescript';
|
|
91
396
|
readonly sourceCode: string;
|
|
92
397
|
}
|
|
93
398
|
|
|
399
|
+
interface MutableProjectContext {
|
|
400
|
+
authoredMcpSurfaceBindingSets:
|
|
401
|
+
| readonly AuthoredMcpSurfaceBindingSet[]
|
|
402
|
+
| undefined;
|
|
403
|
+
entityReferencesByName: Map<string, Set<string>>;
|
|
404
|
+
crudTableIds: Set<string>;
|
|
405
|
+
composeTargetTrailIds: Set<string>;
|
|
406
|
+
crudCoverageByEntity: Map<string, Set<string>>;
|
|
407
|
+
knownEntityIds: Set<string>;
|
|
408
|
+
knownResourceIds: Set<string>;
|
|
409
|
+
knownSignalIds: Set<string>;
|
|
410
|
+
knownTrailIds: Set<string>;
|
|
411
|
+
topoTrailIds: Set<string>;
|
|
412
|
+
importResolutionsByFile: Map<string, readonly WardenImportResolution[]>;
|
|
413
|
+
exportedSymbolDefinitionsByName: Map<
|
|
414
|
+
string,
|
|
415
|
+
readonly WardenExportedSymbolDefinition[]
|
|
416
|
+
>;
|
|
417
|
+
governedVocabularyHistoryByTransitionId: ProjectContext['governedVocabularyHistoryByTransitionId'];
|
|
418
|
+
governedVocabularyHistoryIssues: ProjectContext['governedVocabularyHistoryIssues'];
|
|
419
|
+
governedVocabularyHistoryRequired: ProjectContext['governedVocabularyHistoryRequired'];
|
|
420
|
+
documentedImportResolutionsByFile: Map<
|
|
421
|
+
string,
|
|
422
|
+
readonly WardenImportResolution[]
|
|
423
|
+
>;
|
|
424
|
+
onTargetSignalIds: Set<string>;
|
|
425
|
+
publicWorkspaces: ReturnType<typeof collectPublicWorkspaces>;
|
|
426
|
+
reconcileTableIds: Set<string>;
|
|
427
|
+
trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const createMutableProjectContext = (): MutableProjectContext => ({
|
|
431
|
+
authoredMcpSurfaceBindingSets: undefined,
|
|
432
|
+
composeTargetTrailIds: new Set<string>(),
|
|
433
|
+
crudCoverageByEntity: new Map<string, Set<string>>(),
|
|
434
|
+
crudTableIds: new Set<string>(),
|
|
435
|
+
documentedImportResolutionsByFile: new Map<
|
|
436
|
+
string,
|
|
437
|
+
readonly WardenImportResolution[]
|
|
438
|
+
>(),
|
|
439
|
+
entityReferencesByName: new Map<string, Set<string>>(),
|
|
440
|
+
exportedSymbolDefinitionsByName: new Map(),
|
|
441
|
+
governedVocabularyHistoryByTransitionId: undefined,
|
|
442
|
+
governedVocabularyHistoryIssues: undefined,
|
|
443
|
+
governedVocabularyHistoryRequired: undefined,
|
|
444
|
+
importResolutionsByFile: new Map<string, readonly WardenImportResolution[]>(),
|
|
445
|
+
knownEntityIds: new Set<string>(),
|
|
446
|
+
knownResourceIds: new Set<string>(),
|
|
447
|
+
knownSignalIds: new Set<string>(),
|
|
448
|
+
knownTrailIds: new Set<string>(),
|
|
449
|
+
onTargetSignalIds: new Set<string>(),
|
|
450
|
+
publicWorkspaces: new Map(),
|
|
451
|
+
reconcileTableIds: new Set<string>(),
|
|
452
|
+
topoTrailIds: new Set<string>(),
|
|
453
|
+
trailIntentsById: new Map<string, 'destroy' | 'read' | 'write'>(),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const addEntityReferenceTargets = (
|
|
457
|
+
context: MutableProjectContext,
|
|
458
|
+
entityName: string,
|
|
459
|
+
targets: readonly string[]
|
|
460
|
+
): void => {
|
|
461
|
+
const existing = context.entityReferencesByName.get(entityName);
|
|
462
|
+
if (existing) {
|
|
463
|
+
for (const target of targets) {
|
|
464
|
+
existing.add(target);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
context.entityReferencesByName.set(entityName, new Set(targets));
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const toProjectContext = (context: MutableProjectContext): ProjectContext => ({
|
|
473
|
+
...(context.authoredMcpSurfaceBindingSets === undefined
|
|
474
|
+
? {}
|
|
475
|
+
: { authoredMcpSurfaceBindingSets: context.authoredMcpSurfaceBindingSets }),
|
|
476
|
+
...(context.entityReferencesByName.size > 0
|
|
477
|
+
? {
|
|
478
|
+
entityReferencesByName: new Map(
|
|
479
|
+
[...context.entityReferencesByName.entries()].map(
|
|
480
|
+
([name, targets]) => [name, [...targets]]
|
|
481
|
+
)
|
|
482
|
+
),
|
|
483
|
+
}
|
|
484
|
+
: {}),
|
|
485
|
+
...(context.crudTableIds.size > 0
|
|
486
|
+
? { crudTableIds: context.crudTableIds }
|
|
487
|
+
: {}),
|
|
488
|
+
...(context.crudCoverageByEntity.size > 0
|
|
489
|
+
? {
|
|
490
|
+
crudCoverageByEntity: new Map(
|
|
491
|
+
[...context.crudCoverageByEntity.entries()].map(
|
|
492
|
+
([entityId, operations]) => [
|
|
493
|
+
entityId,
|
|
494
|
+
new Set(operations) as ReadonlySet<string>,
|
|
495
|
+
]
|
|
496
|
+
)
|
|
497
|
+
),
|
|
498
|
+
}
|
|
499
|
+
: {}),
|
|
500
|
+
composeTargetTrailIds: context.composeTargetTrailIds,
|
|
501
|
+
...(context.governedVocabularyHistoryByTransitionId === undefined
|
|
502
|
+
? {}
|
|
503
|
+
: {
|
|
504
|
+
governedVocabularyHistoryByTransitionId:
|
|
505
|
+
context.governedVocabularyHistoryByTransitionId,
|
|
506
|
+
}),
|
|
507
|
+
...(context.governedVocabularyHistoryIssues === undefined
|
|
508
|
+
? {}
|
|
509
|
+
: {
|
|
510
|
+
governedVocabularyHistoryIssues:
|
|
511
|
+
context.governedVocabularyHistoryIssues,
|
|
512
|
+
}),
|
|
513
|
+
...(context.governedVocabularyHistoryRequired === undefined
|
|
514
|
+
? {}
|
|
515
|
+
: {
|
|
516
|
+
governedVocabularyHistoryRequired:
|
|
517
|
+
context.governedVocabularyHistoryRequired,
|
|
518
|
+
}),
|
|
519
|
+
knownEntityIds: context.knownEntityIds,
|
|
520
|
+
knownResourceIds: context.knownResourceIds,
|
|
521
|
+
knownSignalIds: context.knownSignalIds,
|
|
522
|
+
knownTrailIds: context.knownTrailIds,
|
|
523
|
+
...(context.topoTrailIds.size > 0
|
|
524
|
+
? { topoTrailIds: context.topoTrailIds }
|
|
525
|
+
: {}),
|
|
526
|
+
...(context.importResolutionsByFile.size > 0
|
|
527
|
+
? { importResolutionsByFile: context.importResolutionsByFile }
|
|
528
|
+
: {}),
|
|
529
|
+
...(context.documentedImportResolutionsByFile.size > 0
|
|
530
|
+
? {
|
|
531
|
+
documentedImportResolutionsByFile:
|
|
532
|
+
context.documentedImportResolutionsByFile,
|
|
533
|
+
}
|
|
534
|
+
: {}),
|
|
535
|
+
...(context.exportedSymbolDefinitionsByName.size > 0
|
|
536
|
+
? {
|
|
537
|
+
exportedSymbolDefinitionsByName:
|
|
538
|
+
context.exportedSymbolDefinitionsByName,
|
|
539
|
+
}
|
|
540
|
+
: {}),
|
|
541
|
+
...(context.onTargetSignalIds.size > 0
|
|
542
|
+
? { onTargetSignalIds: context.onTargetSignalIds }
|
|
543
|
+
: {}),
|
|
544
|
+
...(context.publicWorkspaces.size > 0
|
|
545
|
+
? { publicWorkspaces: context.publicWorkspaces }
|
|
546
|
+
: {}),
|
|
547
|
+
...(context.reconcileTableIds.size > 0
|
|
548
|
+
? { reconcileTableIds: context.reconcileTableIds }
|
|
549
|
+
: {}),
|
|
550
|
+
trailIntentsById: context.trailIntentsById,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const collectKnownEntityIds = (
|
|
554
|
+
sourceCode: string,
|
|
555
|
+
filePath: string,
|
|
556
|
+
knownEntityIds: Set<string>
|
|
557
|
+
): void => {
|
|
558
|
+
const ast = parse(filePath, sourceCode);
|
|
559
|
+
if (!ast) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
for (const id of collectEntityDefinitionIds(ast)) {
|
|
563
|
+
knownEntityIds.add(id);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
94
567
|
const collectKnownTrailIds = (
|
|
95
568
|
sourceCode: string,
|
|
96
569
|
filePath: string,
|
|
@@ -105,42 +578,157 @@ const collectKnownTrailIds = (
|
|
|
105
578
|
}
|
|
106
579
|
};
|
|
107
580
|
|
|
108
|
-
const
|
|
581
|
+
const collectComposedTrailIds = (
|
|
109
582
|
sourceCode: string,
|
|
110
583
|
filePath: string,
|
|
111
|
-
|
|
584
|
+
composeTargetTrailIds: Set<string>
|
|
112
585
|
): void => {
|
|
113
586
|
const ast = parse(filePath, sourceCode);
|
|
114
587
|
if (!ast) {
|
|
115
588
|
return;
|
|
116
589
|
}
|
|
117
|
-
for (const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
590
|
+
for (const id of collectComposeTargetTrailIds(ast, sourceCode)) {
|
|
591
|
+
composeTargetTrailIds.add(id);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const collectKnownResourceIds = (
|
|
596
|
+
sourceCode: string,
|
|
597
|
+
filePath: string,
|
|
598
|
+
knownResourceIds: Set<string>
|
|
599
|
+
): void => {
|
|
600
|
+
const ast = parse(filePath, sourceCode);
|
|
601
|
+
if (!ast) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
for (const id of collectResourceDefinitionIds(ast)) {
|
|
605
|
+
knownResourceIds.add(id);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const collectKnownSignalIds = (
|
|
610
|
+
sourceCode: string,
|
|
611
|
+
filePath: string,
|
|
612
|
+
knownSignalIds: Set<string>
|
|
613
|
+
): void => {
|
|
614
|
+
const ast = parse(filePath, sourceCode);
|
|
615
|
+
if (!ast) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
for (const id of collectSignalDefinitionIds(ast)) {
|
|
619
|
+
knownSignalIds.add(id);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const collectTrailIntents = (
|
|
624
|
+
sourceCode: string,
|
|
625
|
+
filePath: string,
|
|
626
|
+
trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>
|
|
627
|
+
): void => {
|
|
628
|
+
const ast = parse(filePath, sourceCode);
|
|
629
|
+
if (!ast) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
for (const [id, intent] of collectTrailIntentsById(ast)) {
|
|
633
|
+
trailIntentsById.set(id, intent);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const collectCrudTableIds = (
|
|
638
|
+
sourceCode: string,
|
|
639
|
+
filePath: string,
|
|
640
|
+
crudTableIds: Set<string>
|
|
641
|
+
): void => {
|
|
642
|
+
const ast = parse(filePath, sourceCode);
|
|
643
|
+
if (!ast) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
for (const id of collectCrudTableIdsFromAst(ast)) {
|
|
647
|
+
crudTableIds.add(id);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const collectOnTargetSignalIds = (
|
|
652
|
+
sourceCode: string,
|
|
653
|
+
filePath: string,
|
|
654
|
+
onTargetSignalIds: Set<string>
|
|
655
|
+
): void => {
|
|
656
|
+
const ast = parse(filePath, sourceCode);
|
|
657
|
+
if (!ast) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
for (const id of collectOnTargetSignalIdsFromAst(ast, sourceCode)) {
|
|
661
|
+
onTargetSignalIds.add(id);
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const collectCrudCoverageByEntity = (
|
|
666
|
+
sourceCode: string,
|
|
667
|
+
filePath: string,
|
|
668
|
+
coverageByEntity: Map<string, Set<string>>
|
|
669
|
+
): void => {
|
|
670
|
+
const ast = parse(filePath, sourceCode);
|
|
671
|
+
if (!ast) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
for (const [entityId, operations] of collectFileCrudCoverage(
|
|
675
|
+
ast,
|
|
676
|
+
sourceCode
|
|
677
|
+
)) {
|
|
678
|
+
const bucket = coverageByEntity.get(entityId) ?? new Set<string>();
|
|
679
|
+
for (const operation of operations) {
|
|
680
|
+
bucket.add(operation);
|
|
121
681
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
682
|
+
coverageByEntity.set(entityId, bucket);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const collectReconcileTableIds = (
|
|
687
|
+
sourceCode: string,
|
|
688
|
+
filePath: string,
|
|
689
|
+
reconcileTableIds: Set<string>
|
|
690
|
+
): void => {
|
|
691
|
+
const ast = parse(filePath, sourceCode);
|
|
692
|
+
if (!ast) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
for (const id of collectReconcileTableIdsFromAst(ast)) {
|
|
696
|
+
reconcileTableIds.add(id);
|
|
132
697
|
}
|
|
133
698
|
};
|
|
134
699
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
700
|
+
interface LoadedSourceFiles {
|
|
701
|
+
readonly collection: SourceTreeCollection | null;
|
|
702
|
+
readonly sourceFiles: readonly SourceFile[];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const loadSourceFiles = async (rootDir: string): Promise<LoadedSourceFiles> => {
|
|
138
706
|
const sourceFiles: SourceFile[] = [];
|
|
707
|
+
const collection = collectSourceTree(rootDir, {
|
|
708
|
+
classify: (entry) => {
|
|
709
|
+
if (
|
|
710
|
+
entry.kind === 'directory' &&
|
|
711
|
+
['.git', 'dist', 'node_modules'].includes(entry.name)
|
|
712
|
+
) {
|
|
713
|
+
return { action: 'skip', reason: 'ignored-directory' };
|
|
714
|
+
}
|
|
715
|
+
if (entry.kind === 'directory') {
|
|
716
|
+
return { action: 'recurse' };
|
|
717
|
+
}
|
|
718
|
+
return entry.kind === 'file'
|
|
719
|
+
? { action: 'collect' }
|
|
720
|
+
: { action: 'skip', reason: 'unsupported-entry' };
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
if (!collection) {
|
|
724
|
+
return { collection, sourceFiles };
|
|
725
|
+
}
|
|
139
726
|
|
|
140
|
-
for (const filePath of collectTsFiles(
|
|
727
|
+
for (const filePath of collectTsFiles(collection)) {
|
|
141
728
|
try {
|
|
142
729
|
sourceFiles.push({
|
|
143
730
|
filePath,
|
|
731
|
+
kind: 'typescript',
|
|
144
732
|
sourceCode: await Bun.file(filePath).text(),
|
|
145
733
|
});
|
|
146
734
|
} catch {
|
|
@@ -148,75 +736,584 @@ const loadSourceFiles = async (
|
|
|
148
736
|
}
|
|
149
737
|
}
|
|
150
738
|
|
|
151
|
-
|
|
152
|
-
|
|
739
|
+
for (const filePath of collectTextScanFiles(collection)) {
|
|
740
|
+
try {
|
|
741
|
+
sourceFiles.push({
|
|
742
|
+
filePath,
|
|
743
|
+
kind: 'text',
|
|
744
|
+
sourceCode: await Bun.file(filePath).text(),
|
|
745
|
+
});
|
|
746
|
+
} catch {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
153
750
|
|
|
154
|
-
const
|
|
155
|
-
|
|
751
|
+
for (const filePath of collectDocumentationFiles(collection)) {
|
|
752
|
+
try {
|
|
753
|
+
sourceFiles.push({
|
|
754
|
+
filePath,
|
|
755
|
+
kind: 'documentation',
|
|
756
|
+
sourceCode: await Bun.file(filePath).text(),
|
|
757
|
+
});
|
|
758
|
+
} catch {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
156
762
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
763
|
+
for (const filePath of collectDevPermitTestFiles(collection)) {
|
|
764
|
+
try {
|
|
765
|
+
sourceFiles.push({
|
|
766
|
+
filePath,
|
|
767
|
+
kind: 'text',
|
|
768
|
+
sourceCode: await Bun.file(filePath).text(),
|
|
769
|
+
});
|
|
770
|
+
} catch {
|
|
163
771
|
continue;
|
|
164
772
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return { collection, sourceFiles };
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const collectTopoKnownIds = (
|
|
779
|
+
appTopo: Topo,
|
|
780
|
+
context: MutableProjectContext
|
|
781
|
+
): void => {
|
|
782
|
+
for (const name of appTopo.entities.keys()) {
|
|
783
|
+
context.knownEntityIds.add(name);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
for (const id of appTopo.trails.keys()) {
|
|
787
|
+
context.knownTrailIds.add(id);
|
|
788
|
+
context.topoTrailIds.add(id);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
for (const id of appTopo.resources.keys()) {
|
|
792
|
+
context.knownResourceIds.add(id);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
for (const id of appTopo.signals.keys()) {
|
|
796
|
+
context.knownSignalIds.add(id);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
const collectTopoComposesAndIntents = (
|
|
801
|
+
appTopo: Topo,
|
|
802
|
+
context: MutableProjectContext
|
|
803
|
+
): void => {
|
|
804
|
+
for (const trail of appTopo.trails.values()) {
|
|
805
|
+
context.trailIntentsById.set(trail.id, trail.intent);
|
|
806
|
+
for (const composedTrailId of trail.composes) {
|
|
807
|
+
context.composeTargetTrailIds.add(composedTrailId);
|
|
169
808
|
}
|
|
170
809
|
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const collectTopoEntityReferences = (
|
|
813
|
+
appTopo: Topo,
|
|
814
|
+
context: MutableProjectContext
|
|
815
|
+
): void => {
|
|
816
|
+
for (const entity of appTopo.listEntities()) {
|
|
817
|
+
addEntityReferenceTargets(
|
|
818
|
+
context,
|
|
819
|
+
entity.name,
|
|
820
|
+
getEntityReferences(entity).map((reference) => reference.entity)
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const collectTopoTrailContext = (
|
|
826
|
+
appTopo: Topo,
|
|
827
|
+
context: MutableProjectContext
|
|
828
|
+
): void => {
|
|
829
|
+
collectTopoKnownIds(appTopo, context);
|
|
830
|
+
collectTopoComposesAndIntents(appTopo, context);
|
|
831
|
+
collectTopoEntityReferences(appTopo, context);
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const collectFileKnownIds = (
|
|
835
|
+
sourceFile: SourceFile,
|
|
836
|
+
context: MutableProjectContext
|
|
837
|
+
): void => {
|
|
838
|
+
collectKnownEntityIds(
|
|
839
|
+
sourceFile.sourceCode,
|
|
840
|
+
sourceFile.filePath,
|
|
841
|
+
context.knownEntityIds
|
|
842
|
+
);
|
|
843
|
+
collectKnownTrailIds(
|
|
844
|
+
sourceFile.sourceCode,
|
|
845
|
+
sourceFile.filePath,
|
|
846
|
+
context.knownTrailIds
|
|
847
|
+
);
|
|
848
|
+
collectKnownResourceIds(
|
|
849
|
+
sourceFile.sourceCode,
|
|
850
|
+
sourceFile.filePath,
|
|
851
|
+
context.knownResourceIds
|
|
852
|
+
);
|
|
853
|
+
collectKnownSignalIds(
|
|
854
|
+
sourceFile.sourceCode,
|
|
855
|
+
sourceFile.filePath,
|
|
856
|
+
context.knownSignalIds
|
|
857
|
+
);
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const collectFileTrailRelationships = (
|
|
861
|
+
sourceFile: SourceFile,
|
|
862
|
+
context: MutableProjectContext
|
|
863
|
+
): void => {
|
|
864
|
+
collectComposedTrailIds(
|
|
865
|
+
sourceFile.sourceCode,
|
|
866
|
+
sourceFile.filePath,
|
|
867
|
+
context.composeTargetTrailIds
|
|
868
|
+
);
|
|
869
|
+
collectTrailIntents(
|
|
870
|
+
sourceFile.sourceCode,
|
|
871
|
+
sourceFile.filePath,
|
|
872
|
+
context.trailIntentsById
|
|
873
|
+
);
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const collectFileSupplementalProjectContext = (
|
|
877
|
+
sourceFile: SourceFile,
|
|
878
|
+
context: MutableProjectContext
|
|
879
|
+
): void => {
|
|
880
|
+
collectCrudTableIds(
|
|
881
|
+
sourceFile.sourceCode,
|
|
882
|
+
sourceFile.filePath,
|
|
883
|
+
context.crudTableIds
|
|
884
|
+
);
|
|
885
|
+
collectOnTargetSignalIds(
|
|
886
|
+
sourceFile.sourceCode,
|
|
887
|
+
sourceFile.filePath,
|
|
888
|
+
context.onTargetSignalIds
|
|
889
|
+
);
|
|
890
|
+
collectReconcileTableIds(
|
|
891
|
+
sourceFile.sourceCode,
|
|
892
|
+
sourceFile.filePath,
|
|
893
|
+
context.reconcileTableIds
|
|
894
|
+
);
|
|
895
|
+
collectCrudCoverageByEntity(
|
|
896
|
+
sourceFile.sourceCode,
|
|
897
|
+
sourceFile.filePath,
|
|
898
|
+
context.crudCoverageByEntity
|
|
899
|
+
);
|
|
900
|
+
};
|
|
171
901
|
|
|
172
|
-
|
|
902
|
+
const collectFileProjectContext = (
|
|
903
|
+
sourceFile: SourceFile,
|
|
904
|
+
context: MutableProjectContext
|
|
905
|
+
): void => {
|
|
906
|
+
collectFileKnownIds(sourceFile, context);
|
|
907
|
+
collectFileTrailRelationships(sourceFile, context);
|
|
908
|
+
collectFileSupplementalProjectContext(sourceFile, context);
|
|
173
909
|
};
|
|
174
910
|
|
|
175
|
-
const
|
|
176
|
-
|
|
911
|
+
const collectFileEntityReferences = (
|
|
912
|
+
sourceFile: SourceFile,
|
|
913
|
+
context: MutableProjectContext
|
|
914
|
+
): void => {
|
|
915
|
+
const ast = parse(sourceFile.filePath, sourceFile.sourceCode);
|
|
916
|
+
if (!ast) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const referencesByName = collectEntityReferenceTargetsByName(
|
|
921
|
+
ast,
|
|
922
|
+
context.knownEntityIds
|
|
923
|
+
);
|
|
924
|
+
for (const [entityName, targets] of referencesByName) {
|
|
925
|
+
addEntityReferenceTargets(context, entityName, targets);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const collectFileImportResolutions = (
|
|
930
|
+
rootDir: string,
|
|
931
|
+
sourceFiles: readonly SourceFile[],
|
|
932
|
+
context: MutableProjectContext
|
|
933
|
+
): void => {
|
|
934
|
+
const resolutionsByFile = collectProjectImportResolutions({
|
|
935
|
+
publicWorkspaces: context.publicWorkspaces,
|
|
936
|
+
rootDir,
|
|
937
|
+
sourceFiles,
|
|
938
|
+
});
|
|
939
|
+
for (const [filePath, resolutions] of resolutionsByFile) {
|
|
940
|
+
context.importResolutionsByFile.set(filePath, resolutions);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const collectFileDocumentedImportResolutions = (
|
|
945
|
+
rootDir: string,
|
|
946
|
+
sourceFiles: readonly SourceFile[],
|
|
947
|
+
context: MutableProjectContext
|
|
948
|
+
): void => {
|
|
949
|
+
const resolutionsByFile = collectProjectDocumentationImportResolutions({
|
|
950
|
+
publicWorkspaces: context.publicWorkspaces,
|
|
951
|
+
rootDir,
|
|
952
|
+
sourceFiles,
|
|
953
|
+
});
|
|
954
|
+
for (const [filePath, resolutions] of resolutionsByFile) {
|
|
955
|
+
context.documentedImportResolutionsByFile.set(filePath, resolutions);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const collectFileExportedSymbolDefinitions = (
|
|
960
|
+
rootDir: string,
|
|
961
|
+
sourceFiles: readonly SourceFile[],
|
|
962
|
+
context: MutableProjectContext
|
|
963
|
+
): void => {
|
|
964
|
+
const definitionsByName = collectProjectExportedSymbolDefinitions({
|
|
965
|
+
publicWorkspaces: context.publicWorkspaces,
|
|
966
|
+
rootDir,
|
|
967
|
+
sourceFiles,
|
|
968
|
+
});
|
|
969
|
+
for (const [name, definitions] of definitionsByName) {
|
|
970
|
+
context.exportedSymbolDefinitionsByName.set(name, definitions);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Resolve per-app authored `mcp` surface bindings across the run's topo
|
|
976
|
+
* targets, so project-aware source rules can compare call-site surface
|
|
977
|
+
* options against the authored default of the app they belong to.
|
|
978
|
+
*
|
|
979
|
+
* Prefers the serialized graph overlays when a target carries a precomputed
|
|
980
|
+
* graph; otherwise reads the app-module overlay registrations. Invalid
|
|
981
|
+
* overlays are skipped here — `surface-overlay-coherence` reports them on
|
|
982
|
+
* the topo-aware path.
|
|
983
|
+
*/
|
|
984
|
+
const collectAuthoredMcpSurfaceBindingSets = (
|
|
985
|
+
topoTargets: readonly WardenTopoTarget[]
|
|
986
|
+
): readonly AuthoredMcpSurfaceBindingSet[] | undefined => {
|
|
987
|
+
const sets: AuthoredMcpSurfaceBindingSet[] = [];
|
|
988
|
+
for (const target of topoTargets) {
|
|
989
|
+
let bindings: SurfaceBindings | undefined;
|
|
990
|
+
try {
|
|
991
|
+
const graphOverlays = target.graph?.overlays;
|
|
992
|
+
bindings =
|
|
993
|
+
graphOverlays === undefined
|
|
994
|
+
? resolveSurfaceOverlayBindings(target.overlays)?.mcp
|
|
995
|
+
: surfaceBindingsFromLockOverlays(graphOverlays)?.mcp;
|
|
996
|
+
} catch {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (bindings === undefined) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
sets.push({
|
|
1003
|
+
appName: target.name ?? target.topo.name,
|
|
1004
|
+
bindings,
|
|
1005
|
+
trailIds: [...target.topo.trails.keys()],
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return sets.length > 0 ? sets : undefined;
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
const buildProjectContext = (
|
|
1012
|
+
sourceFiles: readonly SourceFile[],
|
|
1013
|
+
collection: SourceTreeCollection | null,
|
|
1014
|
+
rootDir: string,
|
|
1015
|
+
appTopos: readonly Topo[] = [],
|
|
1016
|
+
scope: WardenScope = EMPTY_WARDEN_SCOPE,
|
|
1017
|
+
authoredMcpSurfaceBindingSets:
|
|
1018
|
+
| readonly AuthoredMcpSurfaceBindingSet[]
|
|
1019
|
+
| undefined = undefined
|
|
177
1020
|
): ProjectContext => {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
1021
|
+
const context = createMutableProjectContext();
|
|
1022
|
+
const governedHistory = loadGovernedVocabularyHistory(rootDir);
|
|
1023
|
+
context.governedVocabularyHistoryByTransitionId =
|
|
1024
|
+
governedHistory.byTransitionId;
|
|
1025
|
+
context.governedVocabularyHistoryIssues = governedHistory.issues;
|
|
1026
|
+
context.authoredMcpSurfaceBindingSets = authoredMcpSurfaceBindingSets;
|
|
1027
|
+
const typeScriptSourceFiles = sourceFiles.filter(
|
|
1028
|
+
(sourceFile) => sourceFile.kind === 'typescript'
|
|
1029
|
+
);
|
|
1030
|
+
const documentationSourceFiles = sourceFiles.filter(
|
|
1031
|
+
(sourceFile) => sourceFile.kind === 'documentation'
|
|
1032
|
+
);
|
|
1033
|
+
context.publicWorkspaces = collectPublicWorkspaces(rootDir, {
|
|
1034
|
+
collectedPackageJsonPaths: new Set(
|
|
1035
|
+
collection?.files
|
|
1036
|
+
.filter((file) => file.path.endsWith('package.json'))
|
|
1037
|
+
.map((file) => file.path)
|
|
1038
|
+
),
|
|
1039
|
+
exclude: scope.exclude,
|
|
1040
|
+
});
|
|
1041
|
+
context.governedVocabularyHistoryRequired =
|
|
1042
|
+
context.publicWorkspaces.has('@ontrails/warden');
|
|
180
1043
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1044
|
+
if (appTopos.length > 0) {
|
|
1045
|
+
for (const appTopo of appTopos) {
|
|
1046
|
+
collectTopoTrailContext(appTopo, context);
|
|
1047
|
+
}
|
|
1048
|
+
for (const sourceFile of typeScriptSourceFiles) {
|
|
1049
|
+
collectFileSupplementalProjectContext(sourceFile, context);
|
|
1050
|
+
}
|
|
1051
|
+
} else {
|
|
1052
|
+
for (const sourceFile of typeScriptSourceFiles) {
|
|
1053
|
+
collectFileProjectContext(sourceFile, context);
|
|
1054
|
+
}
|
|
192
1055
|
}
|
|
193
1056
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
1057
|
+
for (const sourceFile of typeScriptSourceFiles) {
|
|
1058
|
+
collectFileEntityReferences(sourceFile, context);
|
|
1059
|
+
}
|
|
1060
|
+
collectFileImportResolutions(rootDir, typeScriptSourceFiles, context);
|
|
1061
|
+
collectFileDocumentedImportResolutions(
|
|
1062
|
+
rootDir,
|
|
1063
|
+
documentationSourceFiles,
|
|
1064
|
+
context
|
|
1065
|
+
);
|
|
1066
|
+
collectFileExportedSymbolDefinitions(rootDir, typeScriptSourceFiles, context);
|
|
1067
|
+
|
|
1068
|
+
return toProjectContext(context);
|
|
198
1069
|
};
|
|
199
1070
|
|
|
200
1071
|
const isProjectAwareRule = (rule: WardenRule): rule is ProjectAwareWardenRule =>
|
|
201
1072
|
'checkWithContext' in rule;
|
|
202
1073
|
|
|
1074
|
+
const createOptionsDiagnostic = (message: string): WardenDiagnostic => ({
|
|
1075
|
+
filePath: '<warden-options>',
|
|
1076
|
+
line: 1,
|
|
1077
|
+
message,
|
|
1078
|
+
rule: 'warden-options',
|
|
1079
|
+
severity: 'error',
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
const emptyProjectWardenRules = (): ProjectWardenRules => ({
|
|
1083
|
+
diagnostics: [],
|
|
1084
|
+
sourceRules: [],
|
|
1085
|
+
topoRules: [],
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
const loadProjectRulesForRun = async (
|
|
1089
|
+
rootDir: string,
|
|
1090
|
+
options: WardenRunOptions,
|
|
1091
|
+
runLint: boolean
|
|
1092
|
+
): Promise<ProjectWardenRules> => {
|
|
1093
|
+
if (!runLint || options.projectRules === false) {
|
|
1094
|
+
return emptyProjectWardenRules();
|
|
1095
|
+
}
|
|
1096
|
+
return loadProjectWardenRules(rootDir);
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
interface WardenRuleSelector {
|
|
1100
|
+
readonly depth?: WardenDepth | undefined;
|
|
1101
|
+
readonly tier?: WardenRuleTier | undefined;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const depthIncludesTier = (
|
|
1105
|
+
depth: WardenDepth,
|
|
1106
|
+
tier: WardenRuleTier
|
|
1107
|
+
): boolean => {
|
|
1108
|
+
switch (depth) {
|
|
1109
|
+
case 'source': {
|
|
1110
|
+
return tier === 'source-static';
|
|
1111
|
+
}
|
|
1112
|
+
case 'project': {
|
|
1113
|
+
return tier === 'source-static' || tier === 'project-static';
|
|
1114
|
+
}
|
|
1115
|
+
case 'topo': {
|
|
1116
|
+
return (
|
|
1117
|
+
tier === 'source-static' ||
|
|
1118
|
+
tier === 'project-static' ||
|
|
1119
|
+
tier === 'topo-aware'
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
case 'all': {
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
default: {
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
const ruleMatchesTier = (
|
|
1132
|
+
metadata: ReturnType<typeof getWardenRuleMetadata>,
|
|
1133
|
+
tier: WardenRuleTier | undefined
|
|
1134
|
+
): boolean => {
|
|
1135
|
+
if (!tier) {
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (!metadata) {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return tier === 'advisory'
|
|
1144
|
+
? metadata.scope === 'advisory'
|
|
1145
|
+
: metadata.tier === tier;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
const ruleMatchesDepth = (
|
|
1149
|
+
metadata: ReturnType<typeof getWardenRuleMetadata>,
|
|
1150
|
+
depth: WardenDepth | undefined
|
|
1151
|
+
): boolean => {
|
|
1152
|
+
if (!depth) {
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (!metadata) {
|
|
1157
|
+
return false;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (metadata.scope === 'advisory') {
|
|
1161
|
+
return depth === 'all';
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return depthIncludesTier(depth, metadata.tier);
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
const isSelectedRule = (
|
|
1168
|
+
rule: WardenRule | TopoAwareWardenRule,
|
|
1169
|
+
selector: WardenRuleSelector
|
|
1170
|
+
): boolean => {
|
|
1171
|
+
const metadata = getWardenRuleMetadata(rule);
|
|
1172
|
+
return selector.tier
|
|
1173
|
+
? ruleMatchesTier(metadata, selector.tier)
|
|
1174
|
+
: ruleMatchesDepth(metadata, selector.depth);
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
const selectorIncludesProjectChecks = (
|
|
1178
|
+
selector: WardenRuleSelector
|
|
1179
|
+
): boolean => {
|
|
1180
|
+
if (selector.tier !== undefined) {
|
|
1181
|
+
return selector.tier !== 'source-static';
|
|
1182
|
+
}
|
|
1183
|
+
return (
|
|
1184
|
+
selector.depth === undefined ||
|
|
1185
|
+
depthIncludesTier(selector.depth, 'project-static')
|
|
1186
|
+
);
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
const isSelectedProjectRule = (
|
|
1190
|
+
rule: WardenRule,
|
|
1191
|
+
selector: WardenRuleSelector
|
|
1192
|
+
): boolean =>
|
|
1193
|
+
selectorIncludesProjectChecks(selector) &&
|
|
1194
|
+
(selector.tier === 'project-static' || isSelectedRule(rule, selector));
|
|
1195
|
+
|
|
1196
|
+
const isSelectedTopoRule = (
|
|
1197
|
+
rule: TopoAwareWardenRule,
|
|
1198
|
+
selector: WardenRuleSelector
|
|
1199
|
+
): boolean => {
|
|
1200
|
+
const metadata = getWardenRuleMetadata(rule);
|
|
1201
|
+
if (selector.tier) {
|
|
1202
|
+
return metadata
|
|
1203
|
+
? ruleMatchesTier(metadata, selector.tier)
|
|
1204
|
+
: selector.tier === 'topo-aware';
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return metadata ? ruleMatchesDepth(metadata, selector.depth) : true;
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const withDiagnosticGuidance = (
|
|
1211
|
+
diagnostic: WardenDiagnostic
|
|
1212
|
+
): WardenDiagnostic => {
|
|
1213
|
+
if (diagnostic.guidance !== undefined) {
|
|
1214
|
+
return diagnostic;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const guidance = getWardenRuleMetadata(diagnostic.rule)?.guidance;
|
|
1218
|
+
return guidance === undefined ? diagnostic : { ...diagnostic, guidance };
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
const topoRuleFailureDiagnostic = (
|
|
1222
|
+
rule: TopoAwareWardenRule,
|
|
1223
|
+
error: unknown
|
|
1224
|
+
): WardenDiagnostic => {
|
|
1225
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
1226
|
+
return {
|
|
1227
|
+
filePath: '<topo>',
|
|
1228
|
+
line: 1,
|
|
1229
|
+
message: `Topo-aware rule "${rule.name}" threw: ${cause.message}`,
|
|
1230
|
+
rule: rule.name,
|
|
1231
|
+
severity: 'error',
|
|
1232
|
+
};
|
|
1233
|
+
};
|
|
1234
|
+
|
|
203
1235
|
/**
|
|
204
|
-
*
|
|
1236
|
+
* Run all registered topo-aware rules against the resolved topo.
|
|
1237
|
+
*
|
|
1238
|
+
* Topo-aware rules fire exactly once per run (not per file) because they
|
|
1239
|
+
* inspect the compiled trail graph, not source text.
|
|
205
1240
|
*/
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
1241
|
+
const lintTopo = async (
|
|
1242
|
+
appTopo: Topo,
|
|
1243
|
+
graph: TopoGraph | undefined,
|
|
1244
|
+
overlays: readonly TopoGraphOverlayRegistration[] | undefined,
|
|
1245
|
+
extraTopoRules: readonly TopoAwareWardenRule[],
|
|
1246
|
+
selector: WardenRuleSelector
|
|
1247
|
+
): Promise<readonly WardenDiagnostic[]> => {
|
|
1248
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
1249
|
+
const rules: readonly TopoAwareWardenRule[] = [
|
|
1250
|
+
...wardenTopoRules.values(),
|
|
1251
|
+
...extraTopoRules,
|
|
1252
|
+
].filter((rule) => isSelectedTopoRule(rule, selector));
|
|
1253
|
+
let contextGraph: TopoGraph;
|
|
1254
|
+
try {
|
|
1255
|
+
contextGraph = graph ?? deriveTopoGraph(appTopo, { overlays });
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
for (const rule of rules) {
|
|
1258
|
+
diagnostics.push(topoRuleFailureDiagnostic(rule, error));
|
|
1259
|
+
}
|
|
1260
|
+
return diagnostics;
|
|
1261
|
+
}
|
|
215
1262
|
|
|
1263
|
+
for (const rule of rules) {
|
|
1264
|
+
try {
|
|
1265
|
+
diagnostics.push(
|
|
1266
|
+
...(await rule.checkTopo(appTopo, { graph: contextGraph }))
|
|
1267
|
+
);
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
diagnostics.push(topoRuleFailureDiagnostic(rule, error));
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return diagnostics;
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const lintSourceFiles = (
|
|
1276
|
+
sourceFiles: readonly SourceFile[],
|
|
1277
|
+
context: ProjectContext,
|
|
1278
|
+
extraSourceRules: readonly WardenRule[],
|
|
1279
|
+
selector: WardenRuleSelector
|
|
1280
|
+
): readonly WardenDiagnostic[] => {
|
|
1281
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
1282
|
+
const rules = [...wardenRules.values(), ...extraSourceRules];
|
|
1283
|
+
for (const rule of rules) {
|
|
1284
|
+
if (
|
|
1285
|
+
isSelectedProjectRule(rule, selector) &&
|
|
1286
|
+
isProjectAwareRule(rule) &&
|
|
1287
|
+
rule.checkProject !== undefined
|
|
1288
|
+
) {
|
|
1289
|
+
diagnostics.push(...rule.checkProject(context));
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
216
1292
|
for (const sourceFile of sourceFiles) {
|
|
217
|
-
for (const rule of
|
|
1293
|
+
for (const rule of rules) {
|
|
1294
|
+
if (
|
|
1295
|
+
sourceFile.kind === 'text' &&
|
|
1296
|
+
!rule.sourceKinds?.includes('text') &&
|
|
1297
|
+
rule.name !== 'no-dev-permit-in-source' &&
|
|
1298
|
+
rule.name !== 'public-internal-deep-imports'
|
|
1299
|
+
) {
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (
|
|
1304
|
+
sourceFile.kind === 'documentation' &&
|
|
1305
|
+
!rule.sourceKinds?.includes('documentation') &&
|
|
1306
|
+
rule.name !== 'public-internal-deep-imports'
|
|
1307
|
+
) {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (!isSelectedRule(rule, selector)) {
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
218
1315
|
if (isProjectAwareRule(rule)) {
|
|
219
|
-
|
|
1316
|
+
diagnostics.push(
|
|
220
1317
|
...rule.checkWithContext(
|
|
221
1318
|
sourceFile.sourceCode,
|
|
222
1319
|
sourceFile.filePath,
|
|
@@ -225,44 +1322,496 @@ const lintFiles = async (
|
|
|
225
1322
|
);
|
|
226
1323
|
continue;
|
|
227
1324
|
}
|
|
228
|
-
|
|
229
|
-
allDiagnostics.push(
|
|
1325
|
+
diagnostics.push(
|
|
230
1326
|
...rule.check(sourceFile.sourceCode, sourceFile.filePath)
|
|
231
1327
|
);
|
|
232
1328
|
}
|
|
233
1329
|
}
|
|
1330
|
+
return diagnostics;
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
const tagTopoDiagnostic = (
|
|
1334
|
+
diagnostic: WardenDiagnostic,
|
|
1335
|
+
topoName: string | undefined
|
|
1336
|
+
): WardenDiagnostic =>
|
|
1337
|
+
topoName === undefined ? diagnostic : { ...diagnostic, topoName };
|
|
1338
|
+
|
|
1339
|
+
const lintTopoTargets = async (
|
|
1340
|
+
topoTargets: readonly WardenTopoTarget[],
|
|
1341
|
+
extraTopoRules: readonly TopoAwareWardenRule[],
|
|
1342
|
+
selector: WardenRuleSelector,
|
|
1343
|
+
tagDiagnostics: boolean
|
|
1344
|
+
): Promise<readonly WardenDiagnostic[]> => {
|
|
1345
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
1346
|
+
|
|
1347
|
+
for (const target of topoTargets) {
|
|
1348
|
+
const topoDiagnostics = await lintTopo(
|
|
1349
|
+
target.topo,
|
|
1350
|
+
target.graph,
|
|
1351
|
+
target.overlays,
|
|
1352
|
+
extraTopoRules,
|
|
1353
|
+
selector
|
|
1354
|
+
);
|
|
1355
|
+
const topoName = target.name ?? target.topo.name;
|
|
1356
|
+
diagnostics.push(
|
|
1357
|
+
...(tagDiagnostics
|
|
1358
|
+
? topoDiagnostics.map((diagnostic) =>
|
|
1359
|
+
tagTopoDiagnostic(diagnostic, topoName)
|
|
1360
|
+
)
|
|
1361
|
+
: topoDiagnostics)
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return diagnostics;
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const selectorIncludesTopoRules = (selector: WardenRuleSelector): boolean => {
|
|
1369
|
+
if (selector.tier) {
|
|
1370
|
+
return selector.tier === 'advisory';
|
|
1371
|
+
}
|
|
234
1372
|
|
|
235
|
-
return
|
|
1373
|
+
return !selector.depth || depthIncludesTier(selector.depth, 'topo-aware');
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Lint all files against all warden rules.
|
|
1378
|
+
*/
|
|
1379
|
+
interface WardenLintResult {
|
|
1380
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
1381
|
+
readonly sourceFiles: readonly SourceFile[];
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const lintFiles = async (
|
|
1385
|
+
rootDir: string,
|
|
1386
|
+
drafts: EffectiveWardenConfig['drafts'],
|
|
1387
|
+
scope: EffectiveWardenConfig['scope'],
|
|
1388
|
+
topoTargets: readonly WardenTopoTarget[],
|
|
1389
|
+
extraTopoRules: readonly TopoAwareWardenRule[],
|
|
1390
|
+
extraSourceRules: readonly WardenRule[],
|
|
1391
|
+
selector: WardenRuleSelector
|
|
1392
|
+
): Promise<WardenLintResult> => {
|
|
1393
|
+
if (selector.tier === 'topo-aware') {
|
|
1394
|
+
return {
|
|
1395
|
+
diagnostics: [
|
|
1396
|
+
...(await lintTopoTargets(topoTargets, extraTopoRules, selector, true)),
|
|
1397
|
+
],
|
|
1398
|
+
sourceFiles: [],
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const loaded = await loadSourceFiles(rootDir);
|
|
1403
|
+
const sourceFiles = filterSourceFilesByScope(
|
|
1404
|
+
filterSourceFilesByDraftMode(loaded.sourceFiles, drafts),
|
|
1405
|
+
rootDir,
|
|
1406
|
+
scope
|
|
1407
|
+
);
|
|
1408
|
+
const context = buildProjectContext(
|
|
1409
|
+
sourceFiles,
|
|
1410
|
+
loaded.collection,
|
|
1411
|
+
rootDir,
|
|
1412
|
+
topoTargets.map((target) => target.topo),
|
|
1413
|
+
scope,
|
|
1414
|
+
collectAuthoredMcpSurfaceBindingSets(topoTargets)
|
|
1415
|
+
);
|
|
1416
|
+
const allDiagnostics: WardenDiagnostic[] = [
|
|
1417
|
+
...lintSourceFiles(sourceFiles, context, extraSourceRules, selector),
|
|
1418
|
+
];
|
|
1419
|
+
|
|
1420
|
+
if (
|
|
1421
|
+
topoTargets.length > 0 &&
|
|
1422
|
+
(selector.tier === undefined || selector.tier === 'advisory') &&
|
|
1423
|
+
selectorIncludesTopoRules(selector)
|
|
1424
|
+
) {
|
|
1425
|
+
allDiagnostics.push(
|
|
1426
|
+
...(await lintTopoTargets(
|
|
1427
|
+
topoTargets,
|
|
1428
|
+
extraTopoRules,
|
|
1429
|
+
selector,
|
|
1430
|
+
topoTargets.length > 1
|
|
1431
|
+
))
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return { diagnostics: allDiagnostics, sourceFiles };
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
const topoTargetsFromOptions = (
|
|
1439
|
+
options: WardenRunOptions
|
|
1440
|
+
): readonly WardenTopoTarget[] => {
|
|
1441
|
+
if (options.topos !== undefined && options.topos.length > 0) {
|
|
1442
|
+
return options.topos;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return options.topo ? [{ name: options.topo.name, topo: options.topo }] : [];
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
const aggregateDriftHash = (
|
|
1449
|
+
topoTargets: readonly WardenTopoTarget[],
|
|
1450
|
+
driftResults: readonly DriftResult[]
|
|
1451
|
+
): string => {
|
|
1452
|
+
const currentHashes = new Set(
|
|
1453
|
+
driftResults.map((result) => result.currentHash)
|
|
1454
|
+
);
|
|
1455
|
+
const [onlyHash] = currentHashes;
|
|
1456
|
+
if (currentHashes.size === 1 && onlyHash !== undefined) {
|
|
1457
|
+
return onlyHash;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const payload = driftResults
|
|
1461
|
+
.map((result, index) => {
|
|
1462
|
+
const target = topoTargets[index];
|
|
1463
|
+
return {
|
|
1464
|
+
currentHash: result.currentHash,
|
|
1465
|
+
topoName: target?.name ?? target?.topo.name ?? `topo-${String(index)}`,
|
|
1466
|
+
};
|
|
1467
|
+
})
|
|
1468
|
+
.toSorted((left, right) => left.topoName.localeCompare(right.topoName));
|
|
1469
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
1470
|
+
hasher.update(JSON.stringify(payload));
|
|
1471
|
+
return hasher.digest('hex');
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
const describeTopoDriftHash = (
|
|
1475
|
+
topoTargets: readonly WardenTopoTarget[],
|
|
1476
|
+
driftResults: readonly DriftResult[]
|
|
1477
|
+
): string =>
|
|
1478
|
+
driftResults
|
|
1479
|
+
.map((result, index) => {
|
|
1480
|
+
const target = topoTargets[index];
|
|
1481
|
+
const topoName =
|
|
1482
|
+
target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
|
|
1483
|
+
return `${topoName}=${result.committedHash ?? '<none>'}`;
|
|
1484
|
+
})
|
|
1485
|
+
.join(', ');
|
|
1486
|
+
|
|
1487
|
+
const checkDriftForTopoTargets = async (
|
|
1488
|
+
rootDir: string,
|
|
1489
|
+
topoTargets: readonly WardenTopoTarget[]
|
|
1490
|
+
): Promise<DriftResult> => {
|
|
1491
|
+
if (topoTargets.length <= 1) {
|
|
1492
|
+
const [target] = topoTargets;
|
|
1493
|
+
return checkDrift(
|
|
1494
|
+
rootDir,
|
|
1495
|
+
target?.topo,
|
|
1496
|
+
target === undefined ? undefined : { overlays: target.overlays }
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const driftResults = await Promise.all(
|
|
1501
|
+
topoTargets.map((target) =>
|
|
1502
|
+
checkDrift(rootDir, target.topo, { overlays: target.overlays })
|
|
1503
|
+
)
|
|
1504
|
+
);
|
|
1505
|
+
const committedHashes = new Set(
|
|
1506
|
+
driftResults.map((result) => result.committedHash)
|
|
1507
|
+
);
|
|
1508
|
+
if (committedHashes.size > 1) {
|
|
1509
|
+
return {
|
|
1510
|
+
blockedReason: `multi-topo drift expected one committed trails.lock hash but found conflicting hashes: ${describeTopoDriftHash(topoTargets, driftResults)}`,
|
|
1511
|
+
committedHash: null,
|
|
1512
|
+
currentHash: 'blocked',
|
|
1513
|
+
stale: true,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
const committedHash = driftResults[0]?.committedHash ?? null;
|
|
1517
|
+
const blockedReasons = driftResults.flatMap((result, index) => {
|
|
1518
|
+
if (result.blockedReason === undefined) {
|
|
1519
|
+
return [];
|
|
1520
|
+
}
|
|
1521
|
+
const target = topoTargets[index];
|
|
1522
|
+
const topoName =
|
|
1523
|
+
target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
|
|
1524
|
+
return [`${topoName}: ${result.blockedReason}`];
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
if (blockedReasons.length > 0) {
|
|
1528
|
+
return {
|
|
1529
|
+
blockedReason: blockedReasons.join('; '),
|
|
1530
|
+
committedHash,
|
|
1531
|
+
currentHash: 'blocked',
|
|
1532
|
+
stale: true,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const currentHash = aggregateDriftHash(topoTargets, driftResults);
|
|
1537
|
+
const driftedOverlayNamespaces = [
|
|
1538
|
+
...new Set(
|
|
1539
|
+
driftResults.flatMap((result) => result.driftedOverlayNamespaces ?? [])
|
|
1540
|
+
),
|
|
1541
|
+
].toSorted();
|
|
1542
|
+
return {
|
|
1543
|
+
committedHash,
|
|
1544
|
+
currentHash,
|
|
1545
|
+
...(driftedOverlayNamespaces.length === 0
|
|
1546
|
+
? {}
|
|
1547
|
+
: { driftedOverlayNamespaces }),
|
|
1548
|
+
stale: committedHash !== null && committedHash !== currentHash,
|
|
1549
|
+
};
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
const shouldRunLint = (options: WardenRunOptions): boolean =>
|
|
1553
|
+
options.tier ? options.tier !== 'drift' : !options.driftOnly;
|
|
1554
|
+
|
|
1555
|
+
const adapterDiagnosticsForRun = (
|
|
1556
|
+
rootDir: string,
|
|
1557
|
+
options: WardenRunOptions
|
|
1558
|
+
): readonly WardenDiagnostic[] =>
|
|
1559
|
+
options.adapterCheck ? runWardenAdapterChecks(rootDir) : [];
|
|
1560
|
+
|
|
1561
|
+
const shouldRunDrift = (
|
|
1562
|
+
options: WardenRunOptions,
|
|
1563
|
+
effectiveConfig: EffectiveWardenConfig
|
|
1564
|
+
): boolean => {
|
|
1565
|
+
if (effectiveConfig.lock === 'skip') {
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (options.tier) {
|
|
1570
|
+
return options.tier === 'drift';
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (options.lintOnly) {
|
|
1574
|
+
return false;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
return options.driftOnly || effectiveConfig.depth === 'all';
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
const reportPassed = ({
|
|
1581
|
+
drift,
|
|
1582
|
+
errorCount,
|
|
1583
|
+
failOn,
|
|
1584
|
+
warnCount,
|
|
1585
|
+
}: {
|
|
1586
|
+
readonly drift: DriftResult | null;
|
|
1587
|
+
readonly errorCount: number;
|
|
1588
|
+
readonly failOn: WardenFailOn;
|
|
1589
|
+
readonly warnCount: number;
|
|
1590
|
+
}): boolean =>
|
|
1591
|
+
errorCount === 0 &&
|
|
1592
|
+
(failOn === 'error' || warnCount === 0) &&
|
|
1593
|
+
!(drift?.stale ?? false) &&
|
|
1594
|
+
drift?.blockedReason === undefined;
|
|
1595
|
+
|
|
1596
|
+
const buildCliConfigLayer = (options: WardenRunOptions): WardenConfigLayer => ({
|
|
1597
|
+
...(options.apps ? { apps: [...options.apps] } : {}),
|
|
1598
|
+
...(options.depth ? { depth: options.depth } : {}),
|
|
1599
|
+
...(options.drafts ? { drafts: options.drafts } : {}),
|
|
1600
|
+
...(options.failOn ? { failOn: options.failOn } : {}),
|
|
1601
|
+
...(options.format ? { format: options.format } : {}),
|
|
1602
|
+
...(options.scope ? { scope: options.scope } : {}),
|
|
1603
|
+
...(options.lock ? { lock: options.lock } : {}),
|
|
1604
|
+
...(options.noLockMutation === undefined
|
|
1605
|
+
? {}
|
|
1606
|
+
: { noLockMutation: options.noLockMutation }),
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
const fixSummary = (application: WardenFixApplication): WardenFixSummary => ({
|
|
1610
|
+
applied: application.applied,
|
|
1611
|
+
filesChanged: application.filesChanged,
|
|
1612
|
+
skipped: application.skipped,
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
const filterAppliedFixDiagnostics = (
|
|
1616
|
+
diagnostics: readonly WardenDiagnostic[],
|
|
1617
|
+
appliedDiagnostics: readonly WardenDiagnostic[]
|
|
1618
|
+
): readonly WardenDiagnostic[] => {
|
|
1619
|
+
if (appliedDiagnostics.length === 0) {
|
|
1620
|
+
return diagnostics;
|
|
1621
|
+
}
|
|
1622
|
+
const applied = new Set(appliedDiagnostics);
|
|
1623
|
+
return diagnostics.filter((diagnostic) => !applied.has(diagnostic));
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
const blockedDriftAfterSourceFixes = (): DriftResult => ({
|
|
1627
|
+
blockedReason:
|
|
1628
|
+
'Source fixes were applied; rerun Warden to refresh drift evidence.',
|
|
1629
|
+
committedHash: null,
|
|
1630
|
+
currentHash: 'blocked',
|
|
1631
|
+
stale: true,
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Apply every safe source fix among a run's diagnostics, writing patched files.
|
|
1636
|
+
*
|
|
1637
|
+
* Diagnostics with a safe, edit-bearing fix are grouped by file; each file is
|
|
1638
|
+
* re-read so the rule's recorded offsets stay valid, patched via
|
|
1639
|
+
* {@link applySafeFixesToSource}, and written back only when its source
|
|
1640
|
+
* actually changed. Diagnostics that carry fix metadata but are not safe with
|
|
1641
|
+
* edits (review-required or edit-less) are counted as skipped and left reported
|
|
1642
|
+
* for a human or downstream regrade to resolve. Diagnostics without fix
|
|
1643
|
+
* metadata — including topo diagnostics, which carry no source span — are
|
|
1644
|
+
* neither applied nor counted in `skipped`.
|
|
1645
|
+
*/
|
|
1646
|
+
export const applySafeFixesToFiles = async (
|
|
1647
|
+
diagnostics: readonly WardenDiagnostic[],
|
|
1648
|
+
options: {
|
|
1649
|
+
readonly allowedFilePaths?: ReadonlySet<string> | readonly string[];
|
|
1650
|
+
readonly rootDir: string;
|
|
1651
|
+
}
|
|
1652
|
+
): Promise<WardenFixApplication> => {
|
|
1653
|
+
const rootDir = resolve(options.rootDir);
|
|
1654
|
+
const allowedFilePaths =
|
|
1655
|
+
options.allowedFilePaths === undefined
|
|
1656
|
+
? undefined
|
|
1657
|
+
: new Set(
|
|
1658
|
+
[...options.allowedFilePaths].map((filePath) => resolve(filePath))
|
|
1659
|
+
);
|
|
1660
|
+
const fixableByFile = new Map<string, WardenDiagnostic[]>();
|
|
1661
|
+
let skipped = 0;
|
|
1662
|
+
for (const diagnostic of diagnostics) {
|
|
1663
|
+
if (diagnostic.fix === undefined) {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
if (hasSafeFixEdits(diagnostic)) {
|
|
1667
|
+
const filePath = resolve(diagnostic.filePath);
|
|
1668
|
+
const rootRelativePath = relative(rootDir, filePath);
|
|
1669
|
+
const insideRoot =
|
|
1670
|
+
rootRelativePath.length === 0 ||
|
|
1671
|
+
(!rootRelativePath.startsWith('..') && !isAbsolute(rootRelativePath));
|
|
1672
|
+
if (
|
|
1673
|
+
!insideRoot ||
|
|
1674
|
+
(allowedFilePaths !== undefined && !allowedFilePaths.has(filePath))
|
|
1675
|
+
) {
|
|
1676
|
+
skipped += 1;
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
const bucket = fixableByFile.get(filePath) ?? [];
|
|
1680
|
+
bucket.push(diagnostic);
|
|
1681
|
+
fixableByFile.set(filePath, bucket);
|
|
1682
|
+
} else {
|
|
1683
|
+
skipped += 1;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
let applied = 0;
|
|
1688
|
+
const appliedDiagnostics: WardenDiagnostic[] = [];
|
|
1689
|
+
let filesChanged = 0;
|
|
1690
|
+
for (const [filePath, group] of fixableByFile) {
|
|
1691
|
+
const source = await Bun.file(filePath).text();
|
|
1692
|
+
const result = applySafeFixesToSource(source, group);
|
|
1693
|
+
applied += result.applied.length;
|
|
1694
|
+
appliedDiagnostics.push(...result.applied);
|
|
1695
|
+
if (result.changed) {
|
|
1696
|
+
await Bun.write(filePath, result.patched);
|
|
1697
|
+
filesChanged += 1;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
return { applied, appliedDiagnostics, filesChanged, skipped };
|
|
236
1702
|
};
|
|
237
1703
|
|
|
238
1704
|
/**
|
|
239
1705
|
* Run all warden checks and return a structured report.
|
|
240
1706
|
*/
|
|
241
1707
|
export const runWarden = async (
|
|
242
|
-
options:
|
|
1708
|
+
options: WardenRunOptions = {}
|
|
243
1709
|
): Promise<WardenReport> => {
|
|
244
|
-
const rootDir =
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
1710
|
+
const { rootDir } = resolveTrailsProjectRoot({
|
|
1711
|
+
explicitRootDir: options.rootDir,
|
|
1712
|
+
startDir: process.cwd(),
|
|
1713
|
+
});
|
|
1714
|
+
const { diagnostics: configDiagnostics, effectiveConfig } =
|
|
1715
|
+
resolveWardenConfig({
|
|
1716
|
+
cli: buildCliConfigLayer(options),
|
|
1717
|
+
config: options.config,
|
|
1718
|
+
env: options.env,
|
|
1719
|
+
});
|
|
1720
|
+
const optionDiagnostics =
|
|
1721
|
+
!options.tier && options.lintOnly && options.driftOnly
|
|
1722
|
+
? [
|
|
1723
|
+
createOptionsDiagnostic(
|
|
1724
|
+
'lintOnly and driftOnly cannot both be true. Use tier to select a single Warden mode.'
|
|
1725
|
+
),
|
|
1726
|
+
]
|
|
1727
|
+
: [];
|
|
1728
|
+
const topoTargets = topoTargetsFromOptions(options);
|
|
1729
|
+
const selector = {
|
|
1730
|
+
depth: options.tier ? undefined : effectiveConfig.depth,
|
|
1731
|
+
tier: options.tier,
|
|
1732
|
+
} satisfies WardenRuleSelector;
|
|
1733
|
+
const runLint = shouldRunLint(options);
|
|
1734
|
+
const runDrift = shouldRunDrift(options, effectiveConfig);
|
|
1735
|
+
const projectRules = await loadProjectRulesForRun(rootDir, options, runLint);
|
|
1736
|
+
const lintResult = runLint
|
|
1737
|
+
? await lintFiles(
|
|
1738
|
+
rootDir,
|
|
1739
|
+
effectiveConfig.drafts,
|
|
1740
|
+
effectiveConfig.scope,
|
|
1741
|
+
topoTargets,
|
|
1742
|
+
[...projectRules.topoRules, ...(options.extraTopoRules ?? [])],
|
|
1743
|
+
[...projectRules.sourceRules, ...(options.extraSourceRules ?? [])],
|
|
1744
|
+
selector
|
|
1745
|
+
)
|
|
1746
|
+
: { diagnostics: [], sourceFiles: [] };
|
|
1747
|
+
const adapterDiagnostics = adapterDiagnosticsForRun(rootDir, options);
|
|
1748
|
+
|
|
1749
|
+
const rawDiagnostics = [
|
|
1750
|
+
...configDiagnostics,
|
|
1751
|
+
...projectRules.diagnostics,
|
|
1752
|
+
...optionDiagnostics,
|
|
1753
|
+
...lintResult.diagnostics,
|
|
1754
|
+
...adapterDiagnostics,
|
|
1755
|
+
];
|
|
1756
|
+
const allDiagnostics = rawDiagnostics.map(withDiagnosticGuidance);
|
|
1757
|
+
const fixApplication = options.fix
|
|
1758
|
+
? await applySafeFixesToFiles(allDiagnostics, {
|
|
1759
|
+
allowedFilePaths: lintResult.sourceFiles.map(
|
|
1760
|
+
(sourceFile) => sourceFile.filePath
|
|
1761
|
+
),
|
|
1762
|
+
rootDir,
|
|
1763
|
+
})
|
|
1764
|
+
: undefined;
|
|
1765
|
+
const reportDiagnostics = filterAppliedFixDiagnostics(
|
|
1766
|
+
allDiagnostics,
|
|
1767
|
+
fixApplication?.appliedDiagnostics ?? []
|
|
1768
|
+
);
|
|
1769
|
+
let drift: DriftResult | null = null;
|
|
1770
|
+
if (runDrift) {
|
|
1771
|
+
drift =
|
|
1772
|
+
fixApplication !== undefined && fixApplication.filesChanged > 0
|
|
1773
|
+
? blockedDriftAfterSourceFixes()
|
|
1774
|
+
: await checkDriftForTopoTargets(rootDir, topoTargets);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const errorCount = reportDiagnostics.filter(
|
|
253
1778
|
(d) => d.severity === 'error'
|
|
254
1779
|
).length;
|
|
255
|
-
const warnCount =
|
|
1780
|
+
const warnCount = reportDiagnostics.filter(
|
|
1781
|
+
(d) => d.severity === 'warn'
|
|
1782
|
+
).length;
|
|
1783
|
+
const topoNames =
|
|
1784
|
+
topoTargets.length > 0
|
|
1785
|
+
? topoTargets.map((target) => target.name ?? target.topo.name)
|
|
1786
|
+
: undefined;
|
|
256
1787
|
|
|
257
1788
|
return {
|
|
258
|
-
diagnostics:
|
|
1789
|
+
diagnostics: reportDiagnostics,
|
|
259
1790
|
drift,
|
|
1791
|
+
effectiveConfig,
|
|
260
1792
|
errorCount,
|
|
261
|
-
|
|
1793
|
+
...(fixApplication === undefined
|
|
1794
|
+
? {}
|
|
1795
|
+
: { fixes: fixSummary(fixApplication) }),
|
|
1796
|
+
passed: reportPassed({
|
|
1797
|
+
drift,
|
|
1798
|
+
errorCount,
|
|
1799
|
+
failOn: effectiveConfig.failOn,
|
|
1800
|
+
warnCount,
|
|
1801
|
+
}),
|
|
1802
|
+
...(topoNames === undefined ? {} : { topoNames }),
|
|
262
1803
|
warnCount,
|
|
263
1804
|
};
|
|
264
1805
|
};
|
|
265
1806
|
|
|
1807
|
+
const formatPlainGuidanceLink = (link: WardenGuidanceLink): string => {
|
|
1808
|
+
const target = link.path ?? link.url;
|
|
1809
|
+
if (target === undefined || target === link.label) {
|
|
1810
|
+
return link.label;
|
|
1811
|
+
}
|
|
1812
|
+
return `${link.label} (${target})`;
|
|
1813
|
+
};
|
|
1814
|
+
|
|
266
1815
|
/**
|
|
267
1816
|
* Format the lint section of the report.
|
|
268
1817
|
*/
|
|
@@ -280,6 +1829,25 @@ const formatLintSection = (report: WardenReport): string[] => {
|
|
|
280
1829
|
lines.push(
|
|
281
1830
|
` ${d.filePath}:${String(d.line)} [${prefix}] ${d.rule} ${d.message}`
|
|
282
1831
|
);
|
|
1832
|
+
if (d.guidance !== undefined) {
|
|
1833
|
+
lines.push(` Next: ${d.guidance.summary}`);
|
|
1834
|
+
for (const [index, step] of (d.guidance.steps ?? []).entries()) {
|
|
1835
|
+
lines.push(` ${String(index + 1)}. ${step}`);
|
|
1836
|
+
}
|
|
1837
|
+
if (d.guidance.commands !== undefined) {
|
|
1838
|
+
lines.push(
|
|
1839
|
+
` Commands: ${d.guidance.commands.map((cmd) => `\`${cmd}\``).join(', ')}`
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
if (d.guidance.docs !== undefined) {
|
|
1843
|
+
lines.push(
|
|
1844
|
+
` Docs: ${d.guidance.docs.map(formatPlainGuidanceLink).join(', ')}`
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
if (d.guidance.relatedRules !== undefined) {
|
|
1848
|
+
lines.push(` Related: ${d.guidance.relatedRules.join(', ')}`);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
283
1851
|
}
|
|
284
1852
|
|
|
285
1853
|
return lines;
|
|
@@ -292,8 +1860,11 @@ const formatDriftSection = (drift: DriftResult | null): string[] => {
|
|
|
292
1860
|
if (drift === null) {
|
|
293
1861
|
return [];
|
|
294
1862
|
}
|
|
1863
|
+
if (drift.blockedReason !== undefined) {
|
|
1864
|
+
return [`Drift: blocked (${drift.blockedReason})`, ''];
|
|
1865
|
+
}
|
|
295
1866
|
const label = drift.stale
|
|
296
|
-
?
|
|
1867
|
+
? `Drift: ${staleDriftMessage(drift)}`
|
|
297
1868
|
: 'Drift: clean';
|
|
298
1869
|
return [label, ''];
|
|
299
1870
|
};
|
|
@@ -309,7 +1880,12 @@ const formatResultLine = (report: WardenReport): string => {
|
|
|
309
1880
|
if (report.errorCount > 0) {
|
|
310
1881
|
parts.push(`${report.errorCount} errors`);
|
|
311
1882
|
}
|
|
312
|
-
if (report.
|
|
1883
|
+
if (report.warnCount > 0 && report.effectiveConfig?.failOn === 'warning') {
|
|
1884
|
+
parts.push(`${report.warnCount} warnings`);
|
|
1885
|
+
}
|
|
1886
|
+
if (report.drift?.blockedReason !== undefined) {
|
|
1887
|
+
parts.push('established exports blocked');
|
|
1888
|
+
} else if (report.drift?.stale) {
|
|
313
1889
|
parts.push('drift detected');
|
|
314
1890
|
}
|
|
315
1891
|
return `Result: FAIL (${parts.join(', ')})`;
|