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

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 CHANGED
@@ -1,5 +1,54 @@
1
1
  # @ontrails/warden
2
2
 
3
+ ## 1.0.0-beta.32
4
+
5
+ ### Patch Changes
6
+
7
+ - 3e5c0fc: Export shared diagnostic base types from core and align governance diagnostic
8
+ severity vocabulary across adapter checks, permits, and Warden.
9
+ - f3c4fef: Export a shared `escapeRegExp` helper from core and migrate first-party callers off local copies.
10
+ - cb0a9d8: Export shared workspace package discovery helpers from core and migrate first-party discovery callers.
11
+ - fe72b84: Fold remaining Regrade and Warden scan-target surfaces onto the shared path-scope vocabulary.
12
+ - Updated dependencies [3e5c0fc]
13
+ - Updated dependencies [f3c4fef]
14
+ - Updated dependencies [cb0a9d8]
15
+ - Updated dependencies [8db145e]
16
+ - Updated dependencies [2b819f4]
17
+ - Updated dependencies [21c6dda]
18
+ - Updated dependencies [860ef32]
19
+ - Updated dependencies [fe72b84]
20
+ - @ontrails/adapter-kit@1.0.0-beta.32
21
+ - @ontrails/core@1.0.0-beta.32
22
+ - @ontrails/permits@1.0.0-beta.32
23
+ - @ontrails/topographer@1.0.0-beta.32
24
+ - @ontrails/config@1.0.0-beta.32
25
+ - @ontrails/cli@1.0.0-beta.32
26
+ - @ontrails/store@1.0.0-beta.32
27
+
28
+ ## 1.0.0-beta.31
29
+
30
+ ### Patch Changes
31
+
32
+ - ee9f3ae: Let Warden fix capabilities declare downstream scan targets and have Regrade
33
+ honor those targets for Warden-backed term-rewrite classes.
34
+
35
+ Dogfood the first safe facet-to-trailhead prose rewrite through project-local
36
+ Warden rules and Regrade.
37
+
38
+ - a0126d9: Add Warden `scope.exclude` globs through project config and the Trails CLI
39
+ wrapper so governance runs can exclude local notes, scratch space, and generated
40
+ state without dropping durable skills or plugin assets from scope.
41
+ - 6a26a08: Rename Warden governance scope controls from jurisdiction ignore settings to `scope.exclude` across config, CLI, and Trails surfaces.
42
+ - Updated dependencies [4cd5d4e]
43
+ - Updated dependencies [38907cc]
44
+ - @ontrails/core@1.0.0-beta.31
45
+ - @ontrails/adapter-kit@1.0.0-beta.31
46
+ - @ontrails/cli@1.0.0-beta.31
47
+ - @ontrails/config@1.0.0-beta.31
48
+ - @ontrails/permits@1.0.0-beta.31
49
+ - @ontrails/store@1.0.0-beta.31
50
+ - @ontrails/topographer@1.0.0-beta.31
51
+
3
52
  ## 1.0.0-beta.30
4
53
 
5
54
  ### Patch Changes
package/README.md CHANGED
@@ -47,6 +47,28 @@ Warden uses the shared Trails project-root resolver when a caller does not pass
47
47
 
48
48
  This is the right home for repo-specific migration checks or governance that has not earned a place in `@ontrails/warden` itself.
49
49
 
50
+ ## Scope
51
+
52
+ Use `warden.scope.exclude` in `trails.config.*` to keep generated, scratch, or local planning paths outside Warden governance while leaving durable project paths in scope.
53
+
54
+ ```json
55
+ {
56
+ "warden": {
57
+ "scope": {
58
+ "exclude": [".scratch/**", ".agents/notes/**"]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ For a single run, pass one or more root-relative globs:
65
+
66
+ ```bash
67
+ warden --scope-exclude '.scratch/**' --scope-exclude '.agents/notes/**'
68
+ ```
69
+
70
+ Scope is a governance boundary, not a migration scan boundary. Regrade uses its own `scope.exclude` / `--exclude` controls for migration plans.
71
+
50
72
  A rule module may export `rule`, `rules`, `sourceRule`, `sourceRules`, `topoRule`, or `topoRules`. Rules without explicit metadata receive default repo-local source-static or topo-aware metadata so short migration rules can run without extra ceremony. Project-aware source rules that provide `checkWithContext()` default to repo-local project-static metadata.
51
73
 
52
74
  ```typescript
package/bin/warden.ts CHANGED
@@ -19,6 +19,7 @@ Options:
19
19
  --fail-on <value> error or warning
20
20
  --format <value> summary, github, or json
21
21
  --drafts <value> include, exclude, or only
22
+ --scope-exclude <glob> Root-relative path glob Warden should not govern
22
23
  --lock <value> auto, cached, refresh, or skip
23
24
  --no-lock-mutation Do not write lock artifacts
24
25
  --strict Fail on warnings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/warden",
3
- "version": "1.0.0-beta.30",
3
+ "version": "1.0.0-beta.32",
4
4
  "bin": {
5
5
  "warden": "./bin/warden.ts"
6
6
  },
@@ -28,21 +28,21 @@
28
28
  "clean": "rm -rf dist *.tsbuildinfo"
29
29
  },
30
30
  "dependencies": {
31
- "@ontrails/adapter-kit": "^1.0.0-beta.30",
32
- "@ontrails/cli": "^1.0.0-beta.30",
33
- "@ontrails/config": "^1.0.0-beta.30",
34
- "@ontrails/permits": "^1.0.0-beta.30",
35
- "@ontrails/store": "^1.0.0-beta.30",
31
+ "@ontrails/adapter-kit": "^1.0.0-beta.32",
32
+ "@ontrails/cli": "^1.0.0-beta.32",
33
+ "@ontrails/config": "^1.0.0-beta.32",
34
+ "@ontrails/permits": "^1.0.0-beta.32",
35
+ "@ontrails/store": "^1.0.0-beta.32",
36
36
  "oxc-parser": "^0.121.0",
37
37
  "oxc-resolver": "11.19.1",
38
38
  "oxc-walker": "^1.0.0",
39
39
  "zod": "^4.3.5"
40
40
  },
41
41
  "devDependencies": {
42
- "@ontrails/testing": "^1.0.0-beta.30"
42
+ "@ontrails/testing": "^1.0.0-beta.32"
43
43
  },
44
44
  "peerDependencies": {
45
- "@ontrails/core": "^1.0.0-beta.30",
46
- "@ontrails/topographer": "^1.0.0-beta.30"
45
+ "@ontrails/core": "^1.0.0-beta.32",
46
+ "@ontrails/topographer": "^1.0.0-beta.32"
47
47
  }
48
48
  }
package/src/cli.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  import { isAbsolute, relative, resolve } from 'node:path';
9
9
 
10
10
  import { resolveTrailsProjectRoot } from '@ontrails/config';
11
+ import { getContourReferences, matchesAnyPathGlob } from '@ontrails/core';
11
12
  import type { Topo } from '@ontrails/core';
12
13
  import { deriveTopoGraph } from '@ontrails/topographer';
13
14
  import type { TopoGraph } from '@ontrails/topographer';
14
- import { getContourReferences } from '@ontrails/core';
15
15
 
16
16
  import type {
17
17
  EffectiveWardenConfig,
@@ -20,6 +20,7 @@ import type {
20
20
  WardenDepth,
21
21
  WardenFailOn,
22
22
  WardenFormat,
23
+ WardenScope,
23
24
  WardenLockMode,
24
25
  } from './config.js';
25
26
  import { runWardenAdapterChecks } from './adapter-check.js';
@@ -105,6 +106,8 @@ export interface WardenRunOptions {
105
106
  readonly fix?: boolean | undefined;
106
107
  /** Output format requested by the caller. */
107
108
  readonly format?: WardenFormat | undefined;
109
+ /** Root-relative source paths that Warden should not govern. */
110
+ readonly scope?: WardenScope | undefined;
108
111
  /** Lockfile mode requested by the caller. */
109
112
  readonly lock?: WardenLockMode | undefined;
110
113
  /** Suppress lockfile mutation for CI/pre-push callers. */
@@ -260,6 +263,29 @@ const filterSourceFilesByDraftMode = (
260
263
  draftModeIncludesFile(sourceFile.filePath, drafts)
261
264
  );
262
265
 
266
+ const rootRelativeScopePath = (rootDir: string, filePath: string): string =>
267
+ relative(rootDir, filePath).replaceAll('\\', '/');
268
+
269
+ const filterSourceFilesByScope = (
270
+ sourceFiles: readonly SourceFile[],
271
+ rootDir: string,
272
+ scope: WardenScope
273
+ ): readonly SourceFile[] => {
274
+ if (scope.exclude.length === 0) {
275
+ return sourceFiles;
276
+ }
277
+
278
+ return sourceFiles.filter(
279
+ (sourceFile) =>
280
+ !matchesAnyPathGlob(
281
+ rootRelativeScopePath(rootDir, sourceFile.filePath),
282
+ scope.exclude
283
+ )
284
+ );
285
+ };
286
+
287
+ const EMPTY_WARDEN_SCOPE: WardenScope = { exclude: [] };
288
+
263
289
  const collectDevPermitTestFiles = (dir: string): readonly string[] => {
264
290
  const glob = new Bun.Glob('**/*.ts');
265
291
  let matches: IterableIterator<string>;
@@ -799,6 +825,7 @@ const collectFileImportResolutions = (
799
825
  context: MutableProjectContext
800
826
  ): void => {
801
827
  const resolutionsByFile = collectProjectImportResolutions({
828
+ publicWorkspaces: context.publicWorkspaces,
802
829
  rootDir,
803
830
  sourceFiles,
804
831
  });
@@ -813,6 +840,7 @@ const collectFileDocumentedImportResolutions = (
813
840
  context: MutableProjectContext
814
841
  ): void => {
815
842
  const resolutionsByFile = collectProjectDocumentationImportResolutions({
843
+ publicWorkspaces: context.publicWorkspaces,
816
844
  rootDir,
817
845
  sourceFiles,
818
846
  });
@@ -824,7 +852,8 @@ const collectFileDocumentedImportResolutions = (
824
852
  const buildProjectContext = (
825
853
  sourceFiles: readonly SourceFile[],
826
854
  rootDir: string,
827
- appTopos: readonly Topo[] = []
855
+ appTopos: readonly Topo[] = [],
856
+ scope: WardenScope = EMPTY_WARDEN_SCOPE
828
857
  ): ProjectContext => {
829
858
  const context = createMutableProjectContext();
830
859
  const typeScriptSourceFiles = sourceFiles.filter(
@@ -833,7 +862,9 @@ const buildProjectContext = (
833
862
  const documentationSourceFiles = sourceFiles.filter(
834
863
  (sourceFile) => sourceFile.kind === 'documentation'
835
864
  );
836
- context.publicWorkspaces = collectPublicWorkspaces(rootDir);
865
+ context.publicWorkspaces = collectPublicWorkspaces(rootDir, {
866
+ exclude: scope.exclude,
867
+ });
837
868
 
838
869
  if (appTopos.length > 0) {
839
870
  for (const appTopo of appTopos) {
@@ -1145,6 +1176,7 @@ interface WardenLintResult {
1145
1176
  const lintFiles = async (
1146
1177
  rootDir: string,
1147
1178
  drafts: EffectiveWardenConfig['drafts'],
1179
+ scope: EffectiveWardenConfig['scope'],
1148
1180
  topoTargets: readonly WardenTopoTarget[],
1149
1181
  extraTopoRules: readonly TopoAwareWardenRule[],
1150
1182
  extraSourceRules: readonly WardenRule[],
@@ -1159,14 +1191,16 @@ const lintFiles = async (
1159
1191
  };
1160
1192
  }
1161
1193
 
1162
- const sourceFiles = filterSourceFilesByDraftMode(
1163
- await loadSourceFiles(rootDir),
1164
- drafts
1194
+ const sourceFiles = filterSourceFilesByScope(
1195
+ filterSourceFilesByDraftMode(await loadSourceFiles(rootDir), drafts),
1196
+ rootDir,
1197
+ scope
1165
1198
  );
1166
1199
  const context = buildProjectContext(
1167
1200
  sourceFiles,
1168
1201
  rootDir,
1169
- topoTargets.map((target) => target.topo)
1202
+ topoTargets.map((target) => target.topo),
1203
+ scope
1170
1204
  );
1171
1205
  const allDiagnostics: WardenDiagnostic[] = [
1172
1206
  ...lintSourceFiles(sourceFiles, context, extraSourceRules, selector),
@@ -1339,6 +1373,7 @@ const buildCliConfigLayer = (options: WardenRunOptions): WardenConfigLayer => ({
1339
1373
  ...(options.drafts ? { drafts: options.drafts } : {}),
1340
1374
  ...(options.failOn ? { failOn: options.failOn } : {}),
1341
1375
  ...(options.format ? { format: options.format } : {}),
1376
+ ...(options.scope ? { scope: options.scope } : {}),
1342
1377
  ...(options.lock ? { lock: options.lock } : {}),
1343
1378
  ...(options.noLockMutation === undefined
1344
1379
  ? {}
@@ -1476,6 +1511,7 @@ export const runWarden = async (
1476
1511
  ? await lintFiles(
1477
1512
  rootDir,
1478
1513
  effectiveConfig.drafts,
1514
+ effectiveConfig.scope,
1479
1515
  topoTargets,
1480
1516
  [...projectRules.topoRules, ...(options.extraTopoRules ?? [])],
1481
1517
  [...projectRules.sourceRules, ...(options.extraSourceRules ?? [])],
package/src/command.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  WardenDraftsMode,
27
27
  WardenFailOn,
28
28
  WardenFormat,
29
+ WardenScope,
29
30
  WardenLockMode,
30
31
  } from './config.js';
31
32
  import {
@@ -57,6 +58,7 @@ interface MutableWardenConfigLayer {
57
58
  drafts?: WardenDraftsMode | undefined;
58
59
  failOn?: WardenFailOn | undefined;
59
60
  format?: WardenFormat | undefined;
61
+ scope?: WardenScope | undefined;
60
62
  lock?: WardenLockMode | undefined;
61
63
  noLockMutation?: boolean | undefined;
62
64
  }
@@ -95,6 +97,12 @@ const splitApps = (value: string): readonly string[] =>
95
97
  .map((entry) => entry.trim())
96
98
  .filter((entry) => entry.length > 0);
97
99
 
100
+ const splitCommaDelimitedValues = (value: string): readonly string[] =>
101
+ value
102
+ .split(',')
103
+ .map((entry) => entry.trim())
104
+ .filter((entry) => entry.length > 0);
105
+
98
106
  const isAllowedValue = <T extends string>(
99
107
  value: string,
100
108
  allowed: readonly T[]
@@ -248,6 +256,7 @@ const parseTokens = (
248
256
  'no-lock-mutation': { type: 'boolean' },
249
257
  'pre-push': { type: 'boolean' },
250
258
  'root-dir': { type: 'string' },
259
+ 'scope-exclude': { multiple: true, type: 'string' },
251
260
  strict: { type: 'boolean' },
252
261
  ...valueAliasParseOptions,
253
262
  },
@@ -397,6 +406,19 @@ const applyCommandOption = (
397
406
  state.configPath = value;
398
407
  return;
399
408
  }
409
+ if (token.name === 'scope-exclude') {
410
+ if (value === undefined) {
411
+ state.diagnostics.push(
412
+ diagnostic({ message: '--scope-exclude requires a path glob.' })
413
+ );
414
+ return;
415
+ }
416
+ const existing = state.cli.scope?.exclude ?? [];
417
+ state.cli.scope = {
418
+ exclude: [...existing, ...splitCommaDelimitedValues(value)],
419
+ };
420
+ return;
421
+ }
400
422
  if (token.name === 'fix') {
401
423
  state.fix = true;
402
424
  return;
@@ -821,6 +843,7 @@ const buildRunOptions = ({
821
843
  lock: cli.lock,
822
844
  noLockMutation: cli.noLockMutation,
823
845
  rootDir,
846
+ scope: cli.scope,
824
847
  topos,
825
848
  }),
826
849
  env,
package/src/config.ts CHANGED
@@ -10,6 +10,13 @@ export const wardenDraftsValues = ['include', 'exclude', 'only'] as const;
10
10
 
11
11
  const appNameSchema = z.string().min(1);
12
12
 
13
+ const wardenScopeSchema = z
14
+ .object({
15
+ exclude: z.array(z.string().min(1)).default([]),
16
+ })
17
+ .strict()
18
+ .default({ exclude: [] });
19
+
13
20
  const wardenConfigObjectSchema = z
14
21
  .object({
15
22
  apps: z.array(appNameSchema).min(1).optional(),
@@ -18,6 +25,7 @@ const wardenConfigObjectSchema = z
18
25
  failOn: z.enum(wardenFailOnValues).default('error'),
19
26
  format: z.enum(wardenFormatValues).default('summary'),
20
27
  lock: z.enum(wardenLockValues).default('auto'),
28
+ scope: wardenScopeSchema,
21
29
  })
22
30
  .strict();
23
31
 
@@ -31,6 +39,7 @@ export type WardenDepth = (typeof wardenDepthValues)[number];
31
39
  export type WardenDraftsMode = (typeof wardenDraftsValues)[number];
32
40
  export type WardenFailOn = (typeof wardenFailOnValues)[number];
33
41
  export type WardenFormat = (typeof wardenFormatValues)[number];
42
+ export type WardenScope = z.output<typeof wardenScopeSchema>;
34
43
  export type WardenLockMode = (typeof wardenLockValues)[number];
35
44
 
36
45
  export interface WardenConfigLayer extends Partial<WardenConfig> {
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export type {
18
18
  WardenFixCapability,
19
19
  WardenFixClass,
20
20
  WardenFixEdit,
21
+ WardenFixScanTargets,
21
22
  WardenFixSafety,
22
23
  WardenGuidance,
23
24
  WardenGuidanceLink,
@@ -100,6 +101,7 @@ export type {
100
101
  WardenDraftsMode,
101
102
  WardenFailOn,
102
103
  WardenFormat,
104
+ WardenScope,
103
105
  WardenLockMode,
104
106
  EffectiveWardenConfig,
105
107
  ResolveWardenConfigOptions,
@@ -81,16 +81,42 @@ const exportAliasesForWorkspaces = (
81
81
  return aliases;
82
82
  };
83
83
 
84
+ const resolveOptionsWithWorkspaceAliases = (
85
+ publicWorkspaces: ReadonlyMap<string, WardenPublicWorkspace> | undefined,
86
+ resolveOptions: WardenResolverOptions['resolveOptions'] | undefined
87
+ ): WardenResolverOptions['resolveOptions'] => {
88
+ if (!publicWorkspaces) {
89
+ return resolveOptions;
90
+ }
91
+
92
+ const workspaceAliases = exportAliasesForWorkspaces(publicWorkspaces);
93
+ return {
94
+ ...resolveOptions,
95
+ alias: {
96
+ ...workspaceAliases,
97
+ ...resolveOptions?.alias,
98
+ },
99
+ };
100
+ };
101
+
84
102
  export const collectProjectImportResolutions = ({
103
+ publicWorkspaces,
85
104
  resolveOptions,
86
105
  rootDir,
87
106
  sourceFiles,
88
107
  }: {
108
+ readonly publicWorkspaces?: ReadonlyMap<string, WardenPublicWorkspace>;
89
109
  readonly resolveOptions?: WardenResolverOptions['resolveOptions'];
90
110
  readonly rootDir: string;
91
111
  readonly sourceFiles: readonly WardenProjectContextSourceFile[];
92
112
  }): ReadonlyMap<string, readonly WardenImportResolution[]> => {
93
- const resolver = createWardenResolver({ resolveOptions, rootDir });
113
+ const resolver = createWardenResolver({
114
+ resolveOptions: resolveOptionsWithWorkspaceAliases(
115
+ publicWorkspaces,
116
+ resolveOptions
117
+ ),
118
+ rootDir,
119
+ });
94
120
  const resolutionsByFile = new Map<
95
121
  string,
96
122
  readonly WardenImportResolution[]
@@ -118,13 +144,16 @@ export const collectProjectImportResolutions = ({
118
144
  };
119
145
 
120
146
  export const collectProjectDocumentationImportResolutions = ({
147
+ publicWorkspaces: providedPublicWorkspaces,
121
148
  rootDir,
122
149
  sourceFiles,
123
150
  }: {
151
+ readonly publicWorkspaces?: ReadonlyMap<string, WardenPublicWorkspace>;
124
152
  readonly rootDir: string;
125
153
  readonly sourceFiles: readonly WardenProjectContextSourceFile[];
126
154
  }): ReadonlyMap<string, readonly WardenImportResolution[]> => {
127
- const publicWorkspaces = collectPublicWorkspaces(rootDir);
155
+ const publicWorkspaces =
156
+ providedPublicWorkspaces ?? collectPublicWorkspaces(rootDir);
128
157
  const resolver = createWardenResolver({
129
158
  resolveOptions: { alias: exportAliasesForWorkspaces(publicWorkspaces) },
130
159
  rootDir,
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import { dirname, isAbsolute, resolve } from 'node:path';
11
+ import { escapeRegExp } from '@ontrails/core';
11
12
  import type { AstNode } from './ast.js';
12
13
  import {
13
14
  collectScopeFrameBindings,
@@ -288,9 +289,6 @@ const extractIdentifierName = (node: AstNode | undefined): string | null =>
288
289
 
289
290
  const DEFAULT_RESULT_TYPE_NAMES = new Set(['Result']);
290
291
 
291
- const escapeRegExp = (value: string): string =>
292
- value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
293
-
294
292
  const hasGenericTypeReference = (
295
293
  annotationText: string,
296
294
  typeName: string
@@ -77,6 +77,7 @@ export type {
77
77
  WardenFixCapability,
78
78
  WardenFixClass,
79
79
  WardenFixEdit,
80
+ WardenFixScanTargets,
80
81
  WardenFixSafety,
81
82
  WardenGuidance,
82
83
  WardenGuidanceLink,
@@ -610,7 +610,7 @@ const builtinWardenRuleMetadataInput = {
610
610
  guidance: {
611
611
  docs: [
612
612
  {
613
- label: 'Surface Facets ADR',
613
+ label: 'Trailheads ADR',
614
614
  path: 'docs/adr/drafts/20260603-surface-facets-shape-dense-topos.md',
615
615
  },
616
616
  ],
@@ -620,10 +620,10 @@ const builtinWardenRuleMetadataInput = {
620
620
  'Record explicit visibility-widening acceptance and stable-description metadata when a facet intentionally widens visibility.',
621
621
  ],
622
622
  summary:
623
- 'Keep surface facet maps reviewable before they reach MCP projection.',
623
+ 'Keep trailhead maps reviewable before they reach MCP projection.',
624
624
  },
625
625
  invariant:
626
- 'Surface facet maps avoid selector overlap, hidden visibility widening, and drift-prone dynamic selectors.',
626
+ 'Trailhead maps avoid selector overlap, hidden visibility widening, and drift-prone dynamic selectors.',
627
627
  tier: 'source-static',
628
628
  },
629
629
  'trail-fork-coaching': {
@@ -10,7 +10,7 @@ const toWardenDiagnostic = (
10
10
  line: 1,
11
11
  message: diagnostic.message,
12
12
  rule: `permit.${diagnostic.rule}`,
13
- severity: diagnostic.severity === 'error' ? 'error' : 'warn',
13
+ severity: diagnostic.severity,
14
14
  });
15
15
 
16
16
  export const permitGovernance: TopoAwareWardenRule = {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
2
  import { resolve, sep } from 'node:path';
3
+ import { matchesPathGlob } from '@ontrails/core';
3
4
 
4
5
  import {
5
6
  extractStringLiteral,
@@ -303,50 +304,6 @@ const rootBarrelDiagnostics = (
303
304
  const stripLeadingDotSlash = (path: string): string =>
304
305
  path.startsWith('./') ? path.slice(2) : path;
305
306
 
306
- const escapeRegExp = (value: string): string =>
307
- value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
-
309
- const wildcardPatternSource = (pattern: string): string => {
310
- let regexSource = '';
311
- for (let index = 0; index < pattern.length; index += 1) {
312
- const char = pattern[index];
313
- if (char === '*') {
314
- if (pattern[index + 1] === '*') {
315
- regexSource += '.*';
316
- index += 1;
317
- } else {
318
- regexSource += '[^/]*';
319
- }
320
- continue;
321
- }
322
- regexSource += escapeRegExp(char ?? '');
323
- }
324
- return regexSource;
325
- };
326
-
327
- const segmentWildcardPatternCovers = (
328
- filePath: string,
329
- pattern: string
330
- ): boolean => new RegExp(`^${wildcardPatternSource(pattern)}$`).test(filePath);
331
-
332
- const deepWildcardPatternCovers = (
333
- filePath: string,
334
- pattern: string,
335
- globIndex: number
336
- ): boolean => {
337
- const prefix = pattern.slice(0, globIndex + 1);
338
- if (!filePath.startsWith(prefix)) {
339
- return false;
340
- }
341
- const suffixPattern = pattern.slice(globIndex + '/**/'.length);
342
- if (suffixPattern.length === 0) {
343
- return true;
344
- }
345
- const remainingPath = filePath.slice(prefix.length);
346
- const regexSource = `^(?:.*/)?${wildcardPatternSource(suffixPattern)}$`;
347
- return new RegExp(regexSource).test(remainingPath);
348
- };
349
-
350
307
  const filePatternCovers = (filePath: string, pattern: string): boolean => {
351
308
  const normalizedFilePath = normalizePath(stripLeadingDotSlash(filePath));
352
309
  const normalizedPattern = normalizePath(stripLeadingDotSlash(pattern));
@@ -359,27 +316,10 @@ const filePatternCovers = (filePath: string, pattern: string): boolean => {
359
316
  if (normalizedPattern === '**') {
360
317
  return true;
361
318
  }
362
- if (normalizedPattern.endsWith('/**')) {
363
- const prefix = normalizedPattern.slice(0, -2);
364
- return normalizedFilePath.startsWith(prefix);
365
- }
366
-
367
- const globIndex = normalizedPattern.indexOf('/**/');
368
- if (globIndex === -1) {
369
- if (normalizedPattern.includes('*')) {
370
- return segmentWildcardPatternCovers(
371
- normalizedFilePath,
372
- normalizedPattern
373
- );
374
- }
319
+ if (!normalizedPattern.includes('*') && !normalizedPattern.includes('?')) {
375
320
  return normalizedFilePath.startsWith(`${normalizedPattern}/`);
376
321
  }
377
-
378
- return deepWildcardPatternCovers(
379
- normalizedFilePath,
380
- normalizedPattern,
381
- globIndex
382
- );
322
+ return matchesPathGlob(normalizedFilePath, normalizedPattern);
383
323
  };
384
324
 
385
325
  const filesCoverTarget = (
@@ -356,7 +356,7 @@ export const surfaceFacetCoherence: WardenRule = {
356
356
  return diagnostics;
357
357
  },
358
358
  description:
359
- 'Coach surface facet maps away from selector overlap, hidden visibility widening, and drift-prone dynamic selectors.',
359
+ 'Coach trailhead maps away from selector overlap, hidden visibility widening, and drift-prone dynamic selectors.',
360
360
  name: RULE_NAME,
361
361
  severity: 'warn',
362
362
  };
@@ -554,7 +554,7 @@ const diagnosticForField = (
554
554
  .map((option) => `"${option}"`)
555
555
  .join(
556
556
  ', '
557
- )}). 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.`,
557
+ )}). 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.`,
558
558
  rule: RULE_NAME,
559
559
  severity: 'warn',
560
560
  });
@@ -1,4 +1,10 @@
1
- import type { Intent, Topo } from '@ontrails/core';
1
+ import type {
2
+ DiagnosticSeverity,
3
+ Intent,
4
+ RuleDiagnosticBase,
5
+ ScanTargets,
6
+ Topo,
7
+ } from '@ontrails/core';
2
8
  import type { TopoGraph } from '@ontrails/topographer';
3
9
 
4
10
  import type { WardenDepth } from '../config.js';
@@ -8,7 +14,7 @@ import type { WardenPublicWorkspace } from '../workspaces.js';
8
14
  /**
9
15
  * Severity level for warden diagnostics.
10
16
  */
11
- export type WardenSeverity = 'error' | 'warn';
17
+ export type WardenSeverity = DiagnosticSeverity;
12
18
 
13
19
  /**
14
20
  * Execution tier for a Warden rule.
@@ -130,6 +136,21 @@ export interface WardenFixEdit {
130
136
  readonly replacement: string;
131
137
  }
132
138
 
139
+ /**
140
+ * Source targets a fix class can inspect when projected into downstream tools.
141
+ *
142
+ * Warden itself decides which committed files it scans. This metadata is for
143
+ * consumers such as Regrade that need to derive a narrower collection before
144
+ * invoking the rule against an explicit downstream root.
145
+ */
146
+ export type WardenFixScanTargets = ScanTargets & {
147
+ /**
148
+ * @deprecated Compatibility bridge for Warden-backed Regrade classes that
149
+ * predate PathScope. Prefer Regrade collection scope for new callers.
150
+ */
151
+ readonly ignoredDirectories?: readonly string[];
152
+ };
153
+
133
154
  /**
134
155
  * Per-finding fix metadata attached to a diagnostic.
135
156
  *
@@ -164,6 +185,8 @@ export interface WardenFixCapability {
164
185
  readonly class: WardenFixClass;
165
186
  /** Default safety for fixes this rule emits. */
166
187
  readonly safety: WardenFixSafety;
188
+ /** Downstream scan targets for tools that project this fix capability. */
189
+ readonly scanTargets?: WardenFixScanTargets | undefined;
167
190
  }
168
191
 
169
192
  /**
@@ -191,7 +214,7 @@ export interface WardenRuleMetadata {
191
214
  /**
192
215
  * A single diagnostic reported by a warden rule.
193
216
  */
194
- export interface WardenDiagnostic {
217
+ export interface WardenDiagnostic extends RuleDiagnosticBase {
195
218
  /** Rule identifier, e.g. "no-throw-in-implementation" */
196
219
  readonly rule: string;
197
220
  /** Optional rule-local diagnostic code for checks with multiple stable findings. */
@@ -18,7 +18,7 @@ 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
24
  rule: surfaceFacetCoherence,
@@ -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
  },
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