@ontrails/trails 1.0.0-beta.42 → 1.0.0-beta.45

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,75 @@
1
1
  # trails
2
2
 
3
+ ## 1.0.0-beta.45
4
+
5
+ ### Patch Changes
6
+
7
+ - [`f1bd093`](https://github.com/outfitter-dev/trails/commit/f1bd09395fcf81db0bcb8657030288877c2e26e6): Recognize conditional, aliased, and parenthesized Result provenance while invalidating provenance after reassignment across the implementation-return and redundant-error-wrap rules.
8
+ - [`872a815`](https://github.com/outfitter-dev/trails/commit/872a815243cae63fae5b16022f102d125bc78ac5): Require strict registry checks to prove exact-version consumer availability instead of trusting package access or dist-tags alone.
9
+
10
+ ## 1.0.0-beta.44
11
+
12
+ ### Patch Changes
13
+
14
+ - [`b1fbe57`](https://github.com/outfitter-dev/trails/commit/b1fbe574e6f44d1fecb5e3a000270955c0a77b7b): Publish Bun-validated package tarballs through an npm trusted-publishing adapter
15
+ binding, add exact repository metadata for each public workspace package, and
16
+ correct the native Bun release descriptor to its pack-only runtime boundary.
17
+
18
+ ## 1.0.0-beta.43
19
+
20
+ ### Minor Changes
21
+
22
+ - [`4fb20a6`](https://github.com/outfitter-dev/trails/commit/4fb20a68e1ed98972d99fed8b2df96bfa6804bd3): Derive deterministic, provenance-bearing vocabulary plan proposals from a minimal `from`/`to` seed, including morphology, public and compound identifier review, filename and reference-closure candidates, namespace census, and validated live-topo API preserves. Classified governed transitions retain their registry identity while routing every governed form to review instead of inventing a single safe successor.
23
+ - [`d3215b0`](https://github.com/outfitter-dev/trails/commit/d3215b0966f13af2a72ece9adbcc2c68c70f81b6): Add governed file renames to vocabulary Regrade plans, with move-first derived reference closure and persisted policy-aware evidence across CLI and MCP.
24
+ - [`5a7d22a`](https://github.com/outfitter-dev/trails/commit/5a7d22ab9d674f86b28758f23c3e94a17efb5be1): Add three-tier vocabulary migration scope. Protected historical paths remain
25
+ scanned and counted as `historical-by-policy` without being rewritten, while
26
+ reports expose scope-tier totals and census-expected teaching-surface coverage
27
+ through equivalent CLI and MCP plan schemas. Applied vocabulary occurrences
28
+ remain auditable in history with an `applied` verdict, while policy-only
29
+ evidence does not make an active plan stale merely by being recorded.
30
+ - [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
31
+ to derive/derived for contract-owned fact production and render/rendered for
32
+ surface presentation. Public type, helper, rule, relation, and report names move
33
+ without compatibility aliases; ordinary repository/project nouns remain
34
+ explicit preserves or structured review inventory.
35
+ - [`9f0842e`](https://github.com/outfitter-dev/trails/commit/9f0842ee9d7c7155d86a4fd023760ac0a5636f5d): Retire the temporary root vocabulary-cutover toolchain now that Regrade owns
36
+ structured migration plans, safe rewrites, classification, census, CLI/MCP
37
+ reports, and immutable history. Remove the obsolete source exemptions so
38
+ Oxlint and Warden enforce the durable transition contract directly, and add a
39
+ history-driven Regrade audit surface for current-tree regression checks.
40
+ - [`bd1bd96`](https://github.com/outfitter-dev/trails/commit/bd1bd96b90cd8b55f73061e4078a14cd75bed745): Require committed Regrade provenance for governed vocabulary transitions.
41
+
42
+ Applied governed plans now expose deterministic transition, plan, source,
43
+ safe-apply, and review-follow-up evidence through history results. Warden loads
44
+ committed history into project context, cites it for reintroduced symbols, and
45
+ rejects invalid or missing provenance for transitions that require Regrade in
46
+ the workspace that owns the governed registry, without making downstream apps
47
+ prove the framework's own migrations.
48
+ Portable validation accepts the authoritative numeric file-rename counters
49
+ persisted by Regrade history.
50
+
51
+ ### Patch Changes
52
+
53
+ - [`742387e`](https://github.com/outfitter-dev/trails/commit/742387ea27e1f9fb9490147b871ae8c5a59903ae): Allow release checks to recognize changeset edits that only remove release rows
54
+ for packages absent from the live workspace while rejecting any additional
55
+ changes hidden beside that cleanup.
56
+ - [`18a14e2`](https://github.com/outfitter-dev/trails/commit/18a14e220728be7108f3fe2864455c52305aef9f): Finish the v1 vocabulary reset cleanup by retaining the facet guard, adding a
57
+ durable TopoGraph artifact-family guard, teaching the live lexicon directly in
58
+ agent guidance, and replacing completed reset-family placeholders in public
59
+ Regrade examples and CLI schema help.
60
+ - [`113aed6`](https://github.com/outfitter-dev/trails/commit/113aed62d20041e35b0cf9d6c1b1a18df4b88f57): Rename the dependency-light observability owner from `@ontrails/observe` to
61
+ `@ontrails/observability` as a pre-v1 hard cut. Update dependent packages,
62
+ documentation, package discovery, and the governed Regrade route; no
63
+ compatibility package or old import route is retained.
64
+ - [`0938e7b`](https://github.com/outfitter-dev/trails/commit/0938e7badc0c5470d194139d642b673658d099e0): Fold the removed `@ontrails/tracing` package into the truthful existing
65
+ owners: intrinsic trace contracts remain in core, developer-state tooling now
66
+ lives at `@ontrails/observability/dev`, and the dependency-light OTel adapter
67
+ lives at `@ontrails/observability/otel`. There is intentionally no root-package
68
+ compatibility redirect because the former root had more than one owner.
69
+ - [`504881d`](https://github.com/outfitter-dev/trails/commit/504881df0d15ac92c3234be068c9e1930e1ffddf): Require release checks to prove an exact governed Regrade route, or an explicit
70
+ classified multi-owner fold, whenever a public `@ontrails/*` package disappears
71
+ from the publishable workspace inventory.
72
+
3
73
  ## 1.0.0-beta.42
4
74
 
5
75
  ## 1.0.0-beta.41
package/README.md CHANGED
@@ -18,7 +18,7 @@ Common workflows:
18
18
  - `trails wayfind`, `trails wayfind --trails --intent read`, `trails wayfind <id> --contract`, `trails wayfind <id> --deps`, `trails wayfind <id> --impact`, `trails wayfind pattern "wayfind.*"`, `trails wayfind query "release drift"`, and `trails wayfind diff ...` read graph artifacts through Wayfinder. `trails wayfind file <file> --outline` is the operator-owned live-source exception, assembled with `@ontrails/source` and enriched with saved graph context when available.
19
19
  - `trails schema <command...>` shows accepted CLI routes, aliases, flags, and schemas for an operator command or command namespace.
20
20
  - `trails warden` runs Trails governance checks for contract and architecture drift. Use `--scope-exclude <glob>` or project `warden.scope.exclude` config when local notes, scratch space, or generated state should not be governed by Warden.
21
- - `trails regrade plan <from> <to> --root-dir <path> --json` writes an active Regrade plan, `trails regrade plan --expand` stages wide-net review candidates in that plan, `trails regrade preview` reruns it without writing, and `trails regrade apply` consumes the plan and writes history. Use `plan --expand --dry-run` to inspect staged candidates without writing the active plan, and add `--dry-run` to `apply` to prove the apply path without mutating source. Use `trails regrade plans` and `trails regrade check` when a workspace has active plans that need inspection before apply. MCP exposes the same lifecycle as `trails_plan_regrade`, `trails_list_regrades`, `trails_check_regrade`, `trails_preview_regrade`, and `trails_apply_regrade`.
21
+ - `trails regrade plan <from> <to> --root-dir <path> --json` writes an active Regrade plan, `trails regrade plan --expand` stages wide-net review candidates in that plan, `trails regrade preview` reruns it without writing, and `trails regrade apply` consumes the plan and writes history. The minimal `from`/`to` seed is the primary workflow: planning deterministically derives morphology proposals, source-observed naming and public-identifier review candidates, review-only filename moves with reference-closure evidence, a namespace census, and current live-topo API preserves into the artifact's `derivation` section. Each derived item records provenance; uncertain forms and file moves remain review inventory until explicitly authored into the plan. A malformed or incompatible `trails.lock` fails planning instead of silently dropping live-API preserves. Use `plan --expand --dry-run` to inspect additional observed candidates without writing the active plan, and add `--dry-run` to `apply` to prove the apply path without mutating source. Use `trails regrade plans` and `trails regrade check` when a workspace has active plans that need inspection before apply. MCP exposes the same artifact and lifecycle as `trails_plan_regrade`, `trails_list_regrades`, `trails_check_regrade`, `trails_preview_regrade`, and `trails_apply_regrade`.
22
22
  - `trails guide` remains available for compatibility; prefer `trails wayfind --source live --module <app-module>` or saved-artifact Wayfinder reads for agent navigation.
23
23
 
24
24
  Trails is contract-first: define trails once with typed input, Result output, examples, and meta; the framework derives CLI, MCP, HTTP, and future surfaces from the same contracts.
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@ontrails/trails",
3
- "version": "1.0.0-beta.42",
3
+ "version": "1.0.0-beta.45",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/outfitter-dev/trails.git",
7
+ "directory": "apps/trails"
8
+ },
4
9
  "bin": {
5
10
  "trails": "./bin/trails.ts"
6
11
  },
@@ -27,26 +32,25 @@
27
32
  },
28
33
  "dependencies": {
29
34
  "@clack/prompts": "^1.1.0",
30
- "@ontrails/adapter-kit": "^1.0.0-beta.42",
31
- "@ontrails/cli": "^1.0.0-beta.42",
32
- "@ontrails/commander": "^1.0.0-beta.42",
33
- "@ontrails/config": "^1.0.0-beta.42",
34
- "@ontrails/core": "^1.0.0-beta.42",
35
- "@ontrails/http": "^1.0.0-beta.42",
36
- "@ontrails/mcp": "^1.0.0-beta.42",
37
- "@ontrails/observe": "^1.0.0-beta.42",
38
- "@ontrails/permits": "^1.0.0-beta.42",
39
- "@ontrails/regrade": "^1.0.0-beta.42",
40
- "@ontrails/source": "^1.0.0-beta.42",
41
- "@ontrails/topography": "^1.0.0-beta.42",
42
- "@ontrails/tracing": "^1.0.0-beta.42",
43
- "@ontrails/warden": "^1.0.0-beta.42",
35
+ "@ontrails/adapter-kit": "^1.0.0-beta.45",
36
+ "@ontrails/cli": "^1.0.0-beta.45",
37
+ "@ontrails/commander": "^1.0.0-beta.45",
38
+ "@ontrails/config": "^1.0.0-beta.45",
39
+ "@ontrails/core": "^1.0.0-beta.45",
40
+ "@ontrails/http": "^1.0.0-beta.45",
41
+ "@ontrails/mcp": "^1.0.0-beta.45",
42
+ "@ontrails/observability": "^1.0.0-beta.45",
43
+ "@ontrails/permits": "^1.0.0-beta.45",
44
+ "@ontrails/regrade": "^1.0.0-beta.45",
45
+ "@ontrails/source": "^1.0.0-beta.45",
46
+ "@ontrails/topography": "^1.0.0-beta.45",
47
+ "@ontrails/warden": "^1.0.0-beta.45",
44
48
  "commander": "^14.0.3",
45
49
  "typescript": "^5.9.3",
46
50
  "zod": "^4.3.5"
47
51
  },
48
52
  "devDependencies": {
49
- "@ontrails/cloudflare": "^1.0.0-beta.42",
50
- "@ontrails/testing": "^1.0.0-beta.42"
53
+ "@ontrails/cloudflare": "^1.0.0-beta.45",
54
+ "@ontrails/testing": "^1.0.0-beta.45"
51
55
  }
52
56
  }
@@ -21,6 +21,7 @@ export const trailsMcpIncludedTrails = [
21
21
  'guide',
22
22
  'adjust.regrade',
23
23
  'apply.regrade',
24
+ 'audit.regrade',
24
25
  'check.regrade',
25
26
  'list.regrades',
26
27
  'plan.regrade',
@@ -0,0 +1,482 @@
1
+ import {
2
+ InternalError,
3
+ isPlainObject,
4
+ matchesAnyPathGlob,
5
+ NotFoundError,
6
+ Result,
7
+ ValidationError,
8
+ } from '@ontrails/core';
9
+ import type { Result as TrailsResult } from '@ontrails/core';
10
+ import {
11
+ listVocabularyRegradeAuditPlansFromRegistry,
12
+ regradeReportOutput,
13
+ runVocabularyRegrade,
14
+ vocabularyDispositionValues,
15
+ vocabularyRegradePlanForInput,
16
+ } from '@ontrails/regrade';
17
+ import type { VocabularyRegradePlan } from '@ontrails/regrade';
18
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
19
+ import { basename, extname, join } from 'node:path';
20
+ import { z } from 'zod';
21
+
22
+ import { deriveLiveApiPreserveInventory } from './live-api-preserve.js';
23
+ import {
24
+ regradePlanArtifactSchema,
25
+ rootRelativePath,
26
+ } from './plan-artifact.js';
27
+
28
+ export const regradeAuditInputSchema = z.object({
29
+ failOnOpen: z
30
+ .boolean()
31
+ .default(false)
32
+ .describe('Fail when a completed transition has current-tree residue'),
33
+ includeEntries: z
34
+ .enum(['actionable', 'all'])
35
+ .default('actionable')
36
+ .describe('Report entry detail to include for each audited transition'),
37
+ rootDir: z.string().optional().describe('Workspace root directory'),
38
+ transitionIds: z
39
+ .array(z.string().min(1))
40
+ .optional()
41
+ .describe('Governed transition ids to audit; defaults to applied history'),
42
+ });
43
+
44
+ const regradeAuditTransitionSchema = z.object({
45
+ report: z
46
+ .object({
47
+ dispositions: z
48
+ .partialRecord(
49
+ z.enum(vocabularyDispositionValues),
50
+ z.number().int().nonnegative()
51
+ )
52
+ .describe('Current Markdown occurrence counts by classification'),
53
+ entries: regradeReportOutput.shape.entries
54
+ .readonly()
55
+ .describe('Actionable file-level residue details'),
56
+ occurrences: z
57
+ .number()
58
+ .int()
59
+ .nonnegative()
60
+ .describe('Total classified Markdown occurrences'),
61
+ open: z
62
+ .number()
63
+ .int()
64
+ .nonnegative()
65
+ .describe('Unresolved Markdown occurrences'),
66
+ scanned: z.number().int().nonnegative().describe('Files scanned'),
67
+ status: z.enum(['green', 'open']).describe('Transition audit status'),
68
+ })
69
+ .describe('Concise current-tree audit of the latest applied plan'),
70
+ source: z.string().describe('Committed Regrade history artifact'),
71
+ transitionId: z.string().describe('Stable Regrade transition identity'),
72
+ });
73
+
74
+ export const regradeAuditOutputSchema = z.object({
75
+ gate: z.object({
76
+ open: z
77
+ .number()
78
+ .int()
79
+ .nonnegative()
80
+ .describe('Current-tree unresolved occurrence count'),
81
+ status: z
82
+ .enum(['green', 'open'])
83
+ .describe('Whether every audited transition remains complete'),
84
+ }),
85
+ transitions: z
86
+ .array(regradeAuditTransitionSchema)
87
+ .describe('Applied vocabulary transitions audited against current source'),
88
+ });
89
+
90
+ const regradeAuditHistoryPlanSchema = z.object({
91
+ id: z.string().min(1),
92
+ runs: z.array(z.object({ plan: regradePlanArtifactSchema })).min(1),
93
+ });
94
+
95
+ export type RegradeAuditInput = z.output<typeof regradeAuditInputSchema>;
96
+ export type RegradeAuditOutput = z.output<typeof regradeAuditOutputSchema>;
97
+
98
+ interface RegradeAuditCandidate {
99
+ readonly plan: VocabularyRegradePlan;
100
+ readonly source: string;
101
+ readonly transitionId: string;
102
+ }
103
+
104
+ const historyTransitionId = (rawHistory: unknown): string | undefined => {
105
+ if (!isPlainObject(rawHistory)) {
106
+ return undefined;
107
+ }
108
+ const { id, runs } = rawHistory;
109
+ const latestRun = Array.isArray(runs) ? runs.at(-1) : undefined;
110
+ const planArtifact = isPlainObject(latestRun) ? latestRun['plan'] : undefined;
111
+ const plan = isPlainObject(planArtifact) ? planArtifact['plan'] : undefined;
112
+ const planId = isPlainObject(plan) ? plan['id'] : undefined;
113
+ if (typeof planId === 'string') {
114
+ return planId;
115
+ }
116
+ return typeof id === 'string' ? id : undefined;
117
+ };
118
+
119
+ const selectedHistoryFileNamesFor = (
120
+ selectedTransitionIds: ReadonlySet<string> | null,
121
+ registryPlansById: ReadonlyMap<string, VocabularyRegradePlan>
122
+ ): ReadonlySet<string> =>
123
+ new Set(
124
+ selectedTransitionIds === null
125
+ ? []
126
+ : [...selectedTransitionIds].flatMap((transitionId) => {
127
+ const plan = registryPlansById.get(transitionId);
128
+ return plan === undefined ? [] : [`${plan.from}-to-${plan.to}.json`];
129
+ })
130
+ );
131
+
132
+ const historyIsSelected = (
133
+ rawHistory: unknown,
134
+ historyFile: string,
135
+ selectedTransitionIds: ReadonlySet<string> | null,
136
+ selectedHistoryFileNames: ReadonlySet<string>
137
+ ): boolean =>
138
+ selectedTransitionIds === null ||
139
+ selectedTransitionIds.has(historyTransitionId(rawHistory) ?? '') ||
140
+ selectedHistoryFileNames.has(basename(historyFile));
141
+
142
+ export const mergeAuditPlan = (
143
+ latestPlan: VocabularyRegradePlan,
144
+ registryPlan: VocabularyRegradePlan | undefined
145
+ ): VocabularyRegradePlan => {
146
+ if (registryPlan === undefined) {
147
+ return latestPlan;
148
+ }
149
+ const policyClassified = [
150
+ ...(latestPlan.scope?.policyClassified ?? []),
151
+ ...(registryPlan.scope?.policyClassified ?? []),
152
+ ];
153
+ const teachingSurfaces = [
154
+ ...new Set([
155
+ ...(latestPlan.scope?.teachingSurfaces ?? []),
156
+ ...(registryPlan.scope?.teachingSurfaces ?? []),
157
+ ]),
158
+ ].toSorted();
159
+ const scope =
160
+ latestPlan.scope === undefined && registryPlan.scope === undefined
161
+ ? undefined
162
+ : {
163
+ ...registryPlan.scope,
164
+ ...latestPlan.scope,
165
+ ...(policyClassified.length === 0 ? {} : { policyClassified }),
166
+ ...(teachingSurfaces.length === 0 ? {} : { teachingSurfaces }),
167
+ };
168
+ return {
169
+ ...registryPlan,
170
+ ...latestPlan,
171
+ preserve: [
172
+ ...(latestPlan.preserve ?? []),
173
+ ...(registryPlan.preserve ?? []),
174
+ ],
175
+ ...(scope === undefined ? {} : { scope }),
176
+ };
177
+ };
178
+
179
+ const readRegradeAuditCandidates = (
180
+ rootDir: string,
181
+ selectedTransitionIds: ReadonlySet<string> | null
182
+ ): TrailsResult<readonly RegradeAuditCandidate[], Error> => {
183
+ const historyDir = join(rootDir, '.trails', 'regrade', 'history');
184
+ if (!existsSync(historyDir)) {
185
+ return Result.ok([]);
186
+ }
187
+ let historyFiles: readonly string[];
188
+ try {
189
+ historyFiles = readdirSync(historyDir, { withFileTypes: true })
190
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
191
+ .map((entry) => join(historyDir, entry.name))
192
+ .toSorted();
193
+ } catch (error) {
194
+ return Result.err(
195
+ new InternalError('Failed to list Regrade history artifacts.', {
196
+ ...(error instanceof Error ? { cause: error } : {}),
197
+ context: { historyDir },
198
+ })
199
+ );
200
+ }
201
+ const registryPlansById = new Map(
202
+ listVocabularyRegradeAuditPlansFromRegistry().flatMap((plan) =>
203
+ plan.id === undefined ? [] : [[plan.id, plan] as const]
204
+ )
205
+ );
206
+ const selectedHistoryFileNames = selectedHistoryFileNamesFor(
207
+ selectedTransitionIds,
208
+ registryPlansById
209
+ );
210
+ const selectedHistoriesHaveCanonicalFileNames =
211
+ selectedTransitionIds !== null &&
212
+ selectedHistoryFileNames.size === selectedTransitionIds.size;
213
+ const candidates: RegradeAuditCandidate[] = [];
214
+ for (const historyFile of historyFiles) {
215
+ if (
216
+ selectedHistoriesHaveCanonicalFileNames &&
217
+ !selectedHistoryFileNames.has(basename(historyFile))
218
+ ) {
219
+ continue;
220
+ }
221
+ let rawHistory: unknown;
222
+ try {
223
+ rawHistory = JSON.parse(readFileSync(historyFile, 'utf8'));
224
+ } catch (error) {
225
+ return Result.err(
226
+ new InternalError('Failed to read Regrade history audit plan.', {
227
+ ...(error instanceof Error ? { cause: error } : {}),
228
+ context: { historyFile },
229
+ })
230
+ );
231
+ }
232
+ if (
233
+ !historyIsSelected(
234
+ rawHistory,
235
+ historyFile,
236
+ selectedTransitionIds,
237
+ selectedHistoryFileNames
238
+ )
239
+ ) {
240
+ continue;
241
+ }
242
+ const history = regradeAuditHistoryPlanSchema.safeParse(rawHistory);
243
+ if (!history.success) {
244
+ return Result.err(
245
+ new ValidationError('Invalid Regrade history audit plan.', {
246
+ context: { historyFile, issues: history.error.issues },
247
+ })
248
+ );
249
+ }
250
+ const latestPlan = history.data.runs.at(-1)?.plan.plan as
251
+ | VocabularyRegradePlan
252
+ | undefined;
253
+ if (latestPlan?.kind !== 'vocabulary') {
254
+ continue;
255
+ }
256
+ const transitionId = latestPlan.id ?? history.data.id;
257
+ if (
258
+ selectedTransitionIds !== null &&
259
+ !selectedTransitionIds.has(transitionId)
260
+ ) {
261
+ continue;
262
+ }
263
+ const registryPlan =
264
+ registryPlansById.get(transitionId) ??
265
+ vocabularyRegradePlanForInput(latestPlan.from, latestPlan.to) ??
266
+ undefined;
267
+ candidates.push({
268
+ plan: mergeAuditPlan(latestPlan, registryPlan),
269
+ source: rootRelativePath(rootDir, historyFile),
270
+ transitionId,
271
+ });
272
+ }
273
+ return Result.ok(
274
+ candidates.toSorted((left, right) =>
275
+ left.transitionId.localeCompare(right.transitionId)
276
+ )
277
+ );
278
+ };
279
+
280
+ type VocabularyPolicyClassified = NonNullable<
281
+ NonNullable<VocabularyRegradePlan['scope']>['policyClassified']
282
+ >;
283
+
284
+ export const projectPolicyClassifiedForMarkdownAudit = (
285
+ plan: VocabularyRegradePlan
286
+ ): VocabularyPolicyClassified | undefined =>
287
+ plan.scope?.policyClassified
288
+ ?.map((policy) => ({
289
+ ...policy,
290
+ paths: policy.paths.filter(
291
+ (path) =>
292
+ !plan.scope?.exclude?.some(
293
+ (excludedPath) =>
294
+ excludedPath === path || matchesAnyPathGlob(path, [excludedPath])
295
+ )
296
+ ),
297
+ }))
298
+ .filter((policy) => policy.paths.length > 0)
299
+ .map((policy) => {
300
+ const hasExplicitMarkdownPath = policy.paths.some((path) => {
301
+ const extension = extname(path);
302
+ return extension === '.md' || extension === '.mdx';
303
+ });
304
+ if (policy.expectMatches !== true || hasExplicitMarkdownPath) {
305
+ return policy;
306
+ }
307
+ // A Markdown-only audit cannot prove evidence for extensionless source
308
+ // globs. Markdown evidence requirements must name .md/.mdx explicitly.
309
+ const { expectMatches: _, ...markdownPolicy } = policy;
310
+ return markdownPolicy;
311
+ });
312
+
313
+ export const projectExcludesForMarkdownAudit = (
314
+ plan: VocabularyRegradePlan,
315
+ policyClassified: VocabularyPolicyClassified | undefined
316
+ ): readonly string[] | undefined =>
317
+ plan.scope?.exclude?.filter(
318
+ (excludedPath) =>
319
+ !policyClassified?.some((policy) =>
320
+ policy.paths.some((path) => matchesAnyPathGlob(excludedPath, [path]))
321
+ )
322
+ );
323
+
324
+ export const projectIncludesForMarkdownAudit = (
325
+ plan: VocabularyRegradePlan,
326
+ policyClassified: VocabularyPolicyClassified | undefined
327
+ ): readonly string[] | undefined => {
328
+ if (plan.scope?.include === undefined) {
329
+ return undefined;
330
+ }
331
+ return [
332
+ ...new Set([
333
+ ...plan.scope.include,
334
+ ...(policyClassified?.flatMap((policy) => policy.paths) ?? []),
335
+ ...(plan.scope.teachingSurfaces ?? []),
336
+ ]),
337
+ ].toSorted();
338
+ };
339
+
340
+ const runRegradeAuditCandidate = async (
341
+ candidate: RegradeAuditCandidate,
342
+ input: RegradeAuditInput,
343
+ rootDir: string
344
+ ): Promise<TrailsResult<RegradeAuditOutput['transitions'][number], Error>> => {
345
+ const policyClassified = projectPolicyClassifiedForMarkdownAudit(
346
+ candidate.plan
347
+ );
348
+ const exclude = projectExcludesForMarkdownAudit(
349
+ candidate.plan,
350
+ policyClassified
351
+ );
352
+ const include = projectIncludesForMarkdownAudit(
353
+ candidate.plan,
354
+ policyClassified
355
+ );
356
+ const auditPlan: VocabularyRegradePlan = {
357
+ ...candidate.plan,
358
+ scope: {
359
+ ...candidate.plan.scope,
360
+ extensions: ['.md', '.mdx'],
361
+ ...(exclude === undefined ? {} : { exclude }),
362
+ ...(include === undefined ? {} : { include }),
363
+ ...(policyClassified === undefined ? {} : { policyClassified }),
364
+ },
365
+ };
366
+ const preserveResult = await deriveLiveApiPreserveInventory(
367
+ auditPlan,
368
+ rootDir
369
+ );
370
+ if (preserveResult.isErr()) {
371
+ return preserveResult;
372
+ }
373
+ const reportResult = runVocabularyRegrade({
374
+ apply: false,
375
+ includeEntries: input.includeEntries,
376
+ plan: auditPlan,
377
+ ...(preserveResult.value.length === 0
378
+ ? {}
379
+ : { preserveInventory: preserveResult.value }),
380
+ root: rootDir,
381
+ });
382
+ if (reportResult.isErr()) {
383
+ return reportResult;
384
+ }
385
+ if (reportResult.value === null) {
386
+ return Result.err(
387
+ new NotFoundError('Regrade audit root could not be collected.', {
388
+ context: { rootDir },
389
+ })
390
+ );
391
+ }
392
+ const { run } = reportResult.value;
393
+ if (run === undefined) {
394
+ return Result.err(
395
+ new InternalError('Regrade audit did not produce a vocabulary run.', {
396
+ context: { transitionId: candidate.transitionId },
397
+ })
398
+ );
399
+ }
400
+ const output = regradeAuditTransitionSchema.safeParse({
401
+ report: {
402
+ dispositions: run.report.dispositions,
403
+ entries: reportResult.value.entries,
404
+ occurrences: run.ledger.occurrences.length,
405
+ open: run.report.open,
406
+ scanned: reportResult.value.scanned,
407
+ status: run.report.gate.status,
408
+ },
409
+ source: candidate.source,
410
+ transitionId: candidate.transitionId,
411
+ });
412
+ if (!output.success) {
413
+ return Result.err(
414
+ new InternalError('Regrade audit produced an invalid transition.', {
415
+ context: {
416
+ issues: output.error.issues,
417
+ transitionId: candidate.transitionId,
418
+ },
419
+ })
420
+ );
421
+ }
422
+ return Result.ok(output.data);
423
+ };
424
+
425
+ export const auditRegradeHistory = async (
426
+ input: RegradeAuditInput,
427
+ rootDir: string
428
+ ): Promise<TrailsResult<RegradeAuditOutput, Error>> => {
429
+ const selectedTransitionIds =
430
+ input.transitionIds === undefined ? null : new Set(input.transitionIds);
431
+ const candidatesResult = readRegradeAuditCandidates(
432
+ rootDir,
433
+ selectedTransitionIds
434
+ );
435
+ if (candidatesResult.isErr()) {
436
+ return candidatesResult;
437
+ }
438
+ const candidates = candidatesResult.value;
439
+ if (selectedTransitionIds !== null) {
440
+ const auditedTransitionIds = new Set(
441
+ candidates.map((candidate) => candidate.transitionId)
442
+ );
443
+ const missingTransitionIds = [...selectedTransitionIds].filter(
444
+ (transitionId) => !auditedTransitionIds.has(transitionId)
445
+ );
446
+ if (missingTransitionIds.length > 0) {
447
+ return Result.err(
448
+ new ValidationError(
449
+ 'Selected Regrade audit transitions do not have committed history.',
450
+ { context: { transitionIds: missingTransitionIds.toSorted() } }
451
+ )
452
+ );
453
+ }
454
+ }
455
+ const transitions: RegradeAuditOutput['transitions'][number][] = [];
456
+ for (const candidate of candidates) {
457
+ const transitionResult = await runRegradeAuditCandidate(
458
+ candidate,
459
+ input,
460
+ rootDir
461
+ );
462
+ if (transitionResult.isErr()) {
463
+ return transitionResult;
464
+ }
465
+ transitions.push(transitionResult.value);
466
+ }
467
+ const open = transitions.reduce(
468
+ (total, transition) => total + transition.report.open,
469
+ 0
470
+ );
471
+ return Result.ok({
472
+ gate: {
473
+ open,
474
+ status: transitions.some(
475
+ (transition) => transition.report.status === 'open'
476
+ )
477
+ ? 'open'
478
+ : 'green',
479
+ },
480
+ transitions,
481
+ });
482
+ };