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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +23 -1
  3. package/bin/warden.ts +1 -0
  4. package/package.json +9 -9
  5. package/src/cli.ts +160 -14
  6. package/src/command.ts +48 -5
  7. package/src/config.ts +9 -0
  8. package/src/drift.ts +116 -7
  9. package/src/fix.ts +8 -2
  10. package/src/formatters.ts +3 -8
  11. package/src/index.ts +32 -2
  12. package/src/project-context.ts +286 -3
  13. package/src/rules/ast.ts +4 -0
  14. package/src/rules/duplicate-exported-symbol.ts +211 -0
  15. package/src/rules/duplicate-public-contract.ts +2 -1
  16. package/src/rules/governed-symbol-residue.ts +131 -0
  17. package/src/rules/implementation-returns-result.ts +1 -3
  18. package/src/rules/index.ts +44 -3
  19. package/src/rules/metadata.ts +108 -14
  20. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  21. package/src/rules/no-retired-cross-vocabulary.ts +19 -9
  22. package/src/rules/permit-governance.ts +1 -1
  23. package/src/rules/public-export-example-coverage.ts +5 -0
  24. package/src/rules/public-internal-deep-imports.ts +3 -63
  25. package/src/rules/registry-names.ts +12 -2
  26. package/src/rules/retired-vocabulary.ts +396 -0
  27. package/src/rules/signal-graph-coaching.ts +32 -3
  28. package/src/rules/surface-overlay-coherence.ts +262 -0
  29. package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +46 -42
  30. package/src/rules/trail-fork-coaching.ts +1 -1
  31. package/src/rules/trailhead-override-divergence.ts +356 -0
  32. package/src/rules/types.ts +84 -4
  33. package/src/rules/webhook-route-collision.ts +64 -1
  34. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  35. package/src/trails/duplicate-public-contract.trail.ts +1 -1
  36. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  37. package/src/trails/index.ts +6 -1
  38. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  39. package/src/trails/schema.ts +39 -0
  40. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  41. package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +5 -5
  42. package/src/trails/trail-fork-coaching.trail.ts +1 -1
  43. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  44. package/src/trails/wrap-rule.ts +10 -0
  45. package/src/workspaces.ts +30 -69
@@ -10,10 +10,12 @@ export { deprecationWithoutGuidanceTrail } from './deprecation-without-guidance.
10
10
  export { duplicatePublicContractTrail } from './duplicate-public-contract.trail.js';
11
11
  export { draftFileMarkingTrail } from './draft-file-marking.trail.js';
12
12
  export { draftVisibleDebtTrail } from './draft-visible-debt.trail.js';
13
+ export { duplicateExportedSymbolTrail } from './duplicate-exported-symbol.trail.js';
13
14
  export { errorMappingCompletenessTrail } from './error-mapping-completeness.trail.js';
14
15
  export { exampleValidTrail } from './example-valid.trail.js';
15
16
  export { firesDeclarationsTrail } from './fires-declarations.trail.js';
16
17
  export { forkWithoutPreservedBlazeTrail } from './fork-without-preserved-blaze.trail.js';
18
+ export { governedSymbolResidueTrail } from './governed-symbol-residue.trail.js';
17
19
  export { implementationReturnsResultTrail } from './implementation-returns-result.trail.js';
18
20
  export { incompleteAccessorForStandardOpTrail } from './incomplete-accessor-for-standard-op.trail.js';
19
21
  export { incompleteCrudTrail } from './incomplete-crud.trail.js';
@@ -26,6 +28,7 @@ export { missingReconcileTrail } from './missing-reconcile.trail.js';
26
28
  export { onReferencesExistTrail } from './on-references-exist.trail.js';
27
29
  export { noDevPermitInSourceTrail } from './no-dev-permit-in-source.trail.js';
28
30
  export { noDestructuredComposeTrail } from './no-destructured-compose.trail.js';
31
+ export { noLegacyCliAliasExportTrail } from './no-legacy-cli-alias-export.trail.js';
29
32
  export { noLegacyLayerImportsTrail } from './no-legacy-layer-imports.trail.js';
30
33
  export { noDirectImplementationCallTrail } from './no-direct-implementation-call.trail.js';
31
34
  export { noNativeErrorResultTrail } from './no-native-error-result.trail.js';
@@ -54,8 +57,10 @@ export { resourceMockCoverageTrail } from './resource-mock-coverage.trail.js';
54
57
  export { scheduledDestroyIntentTrail } from './scheduled-destroy-intent.trail.js';
55
58
  export { signalGraphCoachingTrail } from './signal-graph-coaching.trail.js';
56
59
  export { staticResourceAccessorPreferenceTrail } from './static-resource-accessor-preference.trail.js';
57
- export { surfaceFacetCoherenceTrail } from './surface-facet-coherence.trail.js';
60
+ export { surfaceOverlayCoherenceTrail } from './surface-overlay-coherence.trail.js';
61
+ export { surfaceTrailheadCoherenceTrail } from './surface-trailhead-coherence.trail.js';
58
62
  export { trailForkCoachingTrail } from './trail-fork-coaching.trail.js';
63
+ export { trailheadOverrideDivergenceTrail } from './trailhead-override-divergence.trail.js';
59
64
  export { unmaterializedActivationSourceTrail } from './unmaterialized-activation-source.trail.js';
60
65
  export { unreachableDetourShadowingTrail } from './unreachable-detour-shadowing.trail.js';
61
66
  export { validDetourContractTrail } from './valid-detour-contract.trail.js';
@@ -0,0 +1,41 @@
1
+ import { noLegacyCliAliasExport } from '../rules/no-legacy-cli-alias-export.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const noLegacyCliAliasExportTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'apps/example/src/app.ts',
10
+ sourceCode: `import { surfaceOverlay } from '@ontrails/core';\n\nexport const trailsOverlays = [surfaceOverlay({ cli: { ls: 'gear.list' } })];\n`,
11
+ },
12
+ name: 'App modules binding CLI through trailsOverlays are clean',
13
+ },
14
+ {
15
+ expected: {
16
+ diagnostics: [
17
+ {
18
+ filePath: 'apps/example/src/app.ts',
19
+ fix: {
20
+ class: 'export-restructure',
21
+ reason:
22
+ "Legacy CLI alias export 'trailsCliAliases' must be rewritten into a surfaceOverlay({ cli: { ... } }) entry inside the module's trailsOverlays array export; run the Regrade class export-restructure:cli-aliases (TRL-1210) to automate this restructure.",
23
+ safety: 'review',
24
+ },
25
+ line: 1,
26
+ message:
27
+ "Legacy CLI alias export 'trailsCliAliases' was removed in the TRL-1207 surfaces-overlay cutover. Wrap the alias map into surfaceOverlay({ cli: { ... } }) from @ontrails/core inside the module's trailsOverlays array export.",
28
+ rule: 'no-legacy-cli-alias-export',
29
+ severity: 'error',
30
+ },
31
+ ],
32
+ },
33
+ input: {
34
+ filePath: 'apps/example/src/app.ts',
35
+ sourceCode: `export const trailsCliAliases = { 'gear.ls': [['gear', 'ls']] };\n`,
36
+ },
37
+ name: 'Legacy CLI alias exports produce migration diagnostics',
38
+ },
39
+ ],
40
+ rule: noLegacyCliAliasExport,
41
+ });
@@ -85,13 +85,48 @@ export const publicWorkspaceSchema = z.object({
85
85
  rootDir: z.string(),
86
86
  });
87
87
 
88
+ export const exportedSymbolDefinitionSchema = z.object({
89
+ filePath: z.string(),
90
+ kind: z.enum([
91
+ 'class',
92
+ 'const',
93
+ 'enum',
94
+ 'export',
95
+ 'function',
96
+ 'interface',
97
+ 'type',
98
+ ]),
99
+ line: z.number(),
100
+ name: z.string(),
101
+ workspaceName: z.string(),
102
+ workspaceRoot: z.string(),
103
+ });
104
+
88
105
  /**
89
106
  * Extended input for project-aware warden rule trails.
90
107
  *
91
108
  * Adds `knownTrailIds` so the caller can supply compose-file context and avoid
92
109
  * false positives for `@see` references or compose-file contour relationships.
93
110
  */
111
+ export const authoredMcpSurfaceBindingSetSchema = z.object({
112
+ appName: z.string().describe('App/topo label the bindings were authored for'),
113
+ bindings: z
114
+ .record(z.string(), z.union([z.string(), z.array(z.string()).readonly()]))
115
+ .describe('Surfaces overlay mcp bindings: binding name to selector(s)'),
116
+ trailIds: z
117
+ .array(z.string())
118
+ .readonly()
119
+ .describe('Trail ids registered in the owning app topo'),
120
+ });
121
+
94
122
  export const projectAwareRuleInput = ruleInput.extend({
123
+ authoredMcpSurfaceBindingSets: z
124
+ .array(authoredMcpSurfaceBindingSetSchema)
125
+ .readonly()
126
+ .optional()
127
+ .describe(
128
+ 'Per-app authored surfaces overlay mcp bindings with owning-app trail ids'
129
+ ),
95
130
  composeTargetTrailIds: z
96
131
  .array(z.string())
97
132
  .optional()
@@ -114,6 +149,10 @@ export const projectAwareRuleInput = ruleInput.extend({
114
149
  .record(z.string(), z.array(importResolutionSchema))
115
150
  .optional()
116
151
  .describe('Resolved docs/specifier facts keyed by documentation file path'),
152
+ exportedSymbolDefinitionsByName: z
153
+ .record(z.string(), z.array(exportedSymbolDefinitionSchema))
154
+ .optional()
155
+ .describe('Public exported symbol definitions grouped by exported name'),
117
156
  importResolutionsByFile: z
118
157
  .record(z.string(), z.array(importResolutionSchema))
119
158
  .optional()
@@ -0,0 +1,24 @@
1
+ import { Result, topo, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { surfaceOverlayCoherence } from '../rules/surface-overlay-coherence.js';
5
+ import { wrapTopoRule } from './wrap-rule.js';
6
+
7
+ const listTrail = trail('gear.list', {
8
+ blaze: () => Result.ok([]),
9
+ input: z.object({}),
10
+ output: z.array(z.string()),
11
+ });
12
+
13
+ export const surfaceOverlayCoherenceTrail = wrapTopoRule({
14
+ examples: [
15
+ {
16
+ expected: { diagnostics: [] },
17
+ input: {
18
+ topo: topo('surface-overlay-coherence', { listTrail }),
19
+ },
20
+ name: 'Topo without a serialized surfaces overlay stays quiet',
21
+ },
22
+ ],
23
+ rule: surfaceOverlayCoherence,
24
+ });
@@ -1,13 +1,13 @@
1
- import { surfaceFacetCoherence } from '../rules/surface-facet-coherence.js';
1
+ import { surfaceTrailheadCoherence } from '../rules/surface-trailhead-coherence.js';
2
2
  import { wrapRule } from './wrap-rule.js';
3
3
 
4
- export const surfaceFacetCoherenceTrail = wrapRule({
4
+ export const surfaceTrailheadCoherenceTrail = wrapRule({
5
5
  examples: [
6
6
  {
7
7
  expected: { diagnostics: [] },
8
8
  input: {
9
9
  filePath: 'mcp-options.ts',
10
- sourceCode: `export const facets = {
10
+ sourceCode: `export const trailheads = {
11
11
  inspect: {
12
12
  description: "Inspect topo state.",
13
13
  trails: ["survey", "survey.brief"],
@@ -18,8 +18,8 @@ export const surfaceFacetCoherenceTrail = wrapRule({
18
18
  },
19
19
  };`,
20
20
  },
21
- name: 'Reviewable surface facet map',
21
+ name: 'Reviewable trailhead map',
22
22
  },
23
23
  ],
24
- rule: surfaceFacetCoherence,
24
+ rule: surfaceTrailheadCoherence,
25
25
  });
@@ -10,7 +10,7 @@ export const trailForkCoachingTrail = wrapRule({
10
10
  filePath: 'users.ts',
11
11
  line: 9,
12
12
  message:
13
- 'Trail "users.manage" branches on input.action ("create", "delete"). This may be a trail fork hidden as a surface accommodation. If branches change semantics (intent, permits, errors, outputs, lifecycle, or side effects) or structure (selected trail identity), split them into distinct trails, a composing trail, or a surface facet that preserves member identity.',
13
+ 'Trail "users.manage" branches on input.action ("create", "delete"). This may be a trail fork hidden as a surface accommodation. If branches change semantics (intent, permits, errors, outputs, lifecycle, or side effects) or structure (selected trail identity), split them into distinct trails, a composing trail, or a trailhead that preserves member identity.',
14
14
  rule: 'trail-fork-coaching',
15
15
  severity: 'warn',
16
16
  },
@@ -0,0 +1,47 @@
1
+ import { trailheadOverrideDivergence } from '../rules/trailhead-override-divergence.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const trailheadOverrideDivergenceTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ authoredMcpSurfaceBindingSets: [
10
+ {
11
+ appName: 'demo',
12
+ bindings: { inspect: ['survey', 'survey.brief'] },
13
+ trailIds: ['survey', 'survey.brief'],
14
+ },
15
+ ],
16
+ filePath: 'mcp-options.ts',
17
+ sourceCode: `export const trailheads = {
18
+ inspect: {
19
+ description: "Inspect topo state.",
20
+ trails: ["survey", "survey.brief"],
21
+ },
22
+ };`,
23
+ },
24
+ name: 'Aligned call-site override',
25
+ },
26
+ {
27
+ input: {
28
+ authoredMcpSurfaceBindingSets: [
29
+ {
30
+ appName: 'demo',
31
+ bindings: { inspect: ['survey', 'survey.brief'] },
32
+ trailIds: ['survey', 'survey.brief'],
33
+ },
34
+ ],
35
+ filePath: 'mcp-options.ts',
36
+ sourceCode: `export const trailheads = {
37
+ inspect: {
38
+ description: "Inspect topo state.",
39
+ trails: ["survey"],
40
+ },
41
+ };`,
42
+ },
43
+ name: 'Diverging member selectors are flagged',
44
+ },
45
+ ],
46
+ rule: trailheadOverrideDivergence,
47
+ });
@@ -51,6 +51,9 @@ const buildRuleMeta = (rule: WardenRule | TopoAwareWardenRule) => {
51
51
  };
52
52
 
53
53
  const buildProjectContext = (input: ProjectAwareRuleInput): ProjectContext => ({
54
+ ...(input.authoredMcpSurfaceBindingSets
55
+ ? { authoredMcpSurfaceBindingSets: input.authoredMcpSurfaceBindingSets }
56
+ : {}),
54
57
  ...(input.contourReferencesByName
55
58
  ? {
56
59
  contourReferencesByName: new Map(
@@ -88,6 +91,13 @@ const buildProjectContext = (input: ProjectAwareRuleInput): ProjectContext => ({
88
91
  ),
89
92
  }
90
93
  : {}),
94
+ ...(input.exportedSymbolDefinitionsByName
95
+ ? {
96
+ exportedSymbolDefinitionsByName: new Map(
97
+ Object.entries(input.exportedSymbolDefinitionsByName)
98
+ ),
99
+ }
100
+ : {}),
91
101
  ...(input.publicWorkspaces
92
102
  ? { publicWorkspaces: new Map(Object.entries(input.publicWorkspaces)) }
93
103
  : {}),
package/src/workspaces.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  * Workspace-manifest discovery for Warden project-aware rules.
3
3
  */
4
4
 
5
- import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
5
+ import { realpathSync } from 'node:fs';
6
+ import { dirname, join, relative, resolve } from 'node:path';
7
+
8
+ import { listWorkspacePackages, matchesAnyPathGlob } from '@ontrails/core';
7
9
 
8
10
  export interface WardenPublicWorkspace {
9
11
  readonly name: string;
@@ -15,8 +17,8 @@ export interface WardenPublicWorkspace {
15
17
  readonly files?: readonly string[] | undefined;
16
18
  }
17
19
 
18
- interface RootManifest {
19
- readonly workspaces?: unknown;
20
+ export interface WardenWorkspaceCollectionOptions {
21
+ readonly exclude?: readonly string[];
20
22
  }
21
23
 
22
24
  interface PackageManifest {
@@ -37,55 +39,12 @@ const normalizeRealPath = (path: string): string => {
37
39
  }
38
40
  };
39
41
 
42
+ const rootRelativePath = (rootDir: string, path: string): string =>
43
+ normalizePath(relative(rootDir, path));
44
+
40
45
  const isRecord = (value: unknown): value is Record<string, unknown> =>
41
46
  Boolean(value && typeof value === 'object' && !Array.isArray(value));
42
47
 
43
- const readJson = <T>(path: string): T | undefined => {
44
- try {
45
- return JSON.parse(readFileSync(path, 'utf8')) as T;
46
- } catch {
47
- return undefined;
48
- }
49
- };
50
-
51
- const workspacePatternsFromManifest = (
52
- manifest: RootManifest | undefined
53
- ): readonly string[] => {
54
- const { workspaces } = manifest ?? {};
55
- if (Array.isArray(workspaces)) {
56
- return workspaces.filter(
57
- (pattern): pattern is string => typeof pattern === 'string'
58
- );
59
- }
60
-
61
- const packages = isRecord(workspaces) ? workspaces['packages'] : undefined;
62
- return Array.isArray(packages)
63
- ? packages.filter(
64
- (pattern): pattern is string => typeof pattern === 'string'
65
- )
66
- : [];
67
- };
68
-
69
- const workspaceDirsForPattern = (
70
- rootDir: string,
71
- pattern: string
72
- ): readonly string[] => {
73
- if (!pattern.endsWith('/*')) {
74
- const workspaceDir = join(rootDir, pattern);
75
- return existsSync(workspaceDir) ? [workspaceDir] : [];
76
- }
77
-
78
- const groupDir = join(rootDir, pattern.slice(0, -2));
79
- if (!existsSync(groupDir)) {
80
- return [];
81
- }
82
-
83
- return readdirSync(groupDir, { withFileTypes: true })
84
- .filter((entry) => entry.isDirectory())
85
- .map((entry) => join(groupDir, entry.name))
86
- .toSorted();
87
- };
88
-
89
48
  const packageLocalName = (name: string): string =>
90
49
  name.split('/').at(-1) ?? name;
91
50
 
@@ -208,29 +167,31 @@ const publicWorkspaceFromManifest = (
208
167
  };
209
168
 
210
169
  export const collectPublicWorkspaces = (
211
- rootDir: string
170
+ rootDir: string,
171
+ options: WardenWorkspaceCollectionOptions = {}
212
172
  ): ReadonlyMap<string, WardenPublicWorkspace> => {
213
173
  const normalizedRoot = normalizeRealPath(rootDir);
214
- const rootManifest = readJson<RootManifest>(
215
- join(normalizedRoot, 'package.json')
216
- );
217
174
  const workspaces = new Map<string, WardenPublicWorkspace>();
218
175
 
219
- for (const pattern of workspacePatternsFromManifest(rootManifest)) {
220
- for (const workspaceDir of workspaceDirsForPattern(
221
- normalizedRoot,
222
- pattern
223
- )) {
224
- const packageJsonPath = join(workspaceDir, 'package.json');
225
- const manifest = readJson<PackageManifest>(packageJsonPath);
226
- if (!manifest) {
227
- continue;
228
- }
229
-
230
- const workspace = publicWorkspaceFromManifest(packageJsonPath, manifest);
231
- if (workspace) {
232
- workspaces.set(workspace.name, workspace);
233
- }
176
+ for (const workspacePackage of listWorkspacePackages<PackageManifest>(
177
+ normalizedRoot
178
+ )) {
179
+ const workspace = publicWorkspaceFromManifest(
180
+ workspacePackage.packageJsonPath,
181
+ workspacePackage.manifest
182
+ );
183
+ if (
184
+ workspace &&
185
+ !matchesAnyPathGlob(
186
+ rootRelativePath(normalizedRoot, workspace.rootDir),
187
+ options.exclude ?? []
188
+ ) &&
189
+ !matchesAnyPathGlob(
190
+ rootRelativePath(normalizedRoot, workspace.packageJsonPath),
191
+ options.exclude ?? []
192
+ )
193
+ ) {
194
+ workspaces.set(workspace.name, workspace);
234
195
  }
235
196
  }
236
197