@ontrails/trails 0.2.0
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 +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- package/src/versions.ts +31 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Result, ValidationError, escapeRegExp } from '@ontrails/core';
|
|
2
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
3
|
+
import type {
|
|
4
|
+
VocabularyPreserveInventoryEntry,
|
|
5
|
+
VocabularyRegradePlan,
|
|
6
|
+
} from '@ontrails/regrade';
|
|
7
|
+
import { deriveVocabularyFormProposals } from '@ontrails/regrade';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
const lockShapeSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
topoGraph: z
|
|
15
|
+
.object({
|
|
16
|
+
entries: z.array(z.object({ id: z.string() }).passthrough()),
|
|
17
|
+
library: z
|
|
18
|
+
.object({
|
|
19
|
+
exports: z.array(
|
|
20
|
+
z.object({ exportName: z.string() }).passthrough()
|
|
21
|
+
),
|
|
22
|
+
})
|
|
23
|
+
.passthrough()
|
|
24
|
+
.optional(),
|
|
25
|
+
})
|
|
26
|
+
.passthrough(),
|
|
27
|
+
})
|
|
28
|
+
.passthrough();
|
|
29
|
+
|
|
30
|
+
type LockShape = z.output<typeof lockShapeSchema>;
|
|
31
|
+
|
|
32
|
+
const liveApiValues = (
|
|
33
|
+
lock: LockShape
|
|
34
|
+
): readonly {
|
|
35
|
+
readonly evidence: string;
|
|
36
|
+
readonly value: string;
|
|
37
|
+
}[] => {
|
|
38
|
+
const values = new Map<string, string>();
|
|
39
|
+
for (const entry of lock.topoGraph?.entries ?? []) {
|
|
40
|
+
if (typeof entry.id === 'string') {
|
|
41
|
+
values.set(entry.id, `topo.entry:${entry.id}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const item of lock.topoGraph?.library?.exports ?? []) {
|
|
45
|
+
if (typeof item.exportName === 'string') {
|
|
46
|
+
values.set(item.exportName, `topo.library:${item.exportName}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...values.entries()]
|
|
50
|
+
.map(([value, evidence]) => ({ evidence, value }))
|
|
51
|
+
.toSorted((left, right) => left.value.localeCompare(right.value));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const identifierSegments = (value: string): readonly string[] =>
|
|
55
|
+
value
|
|
56
|
+
.split(/[^A-Za-z0-9]+/u)
|
|
57
|
+
.flatMap(
|
|
58
|
+
(part) => part.match(/[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+/gu) ?? []
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const liveApiValueContainsSource = (value: string, source: string): boolean => {
|
|
62
|
+
if (/^[A-Za-z][A-Za-z0-9]*$/u.test(source)) {
|
|
63
|
+
const normalized = source.toLowerCase();
|
|
64
|
+
return identifierSegments(value).some(
|
|
65
|
+
(segment) => segment.toLowerCase() === normalized
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return new RegExp(
|
|
69
|
+
`(?<![A-Za-z0-9_$])${escapeRegExp(source)}(?![A-Za-z0-9_$])`,
|
|
70
|
+
'iu'
|
|
71
|
+
).test(value);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const deriveLiveApiPreserveInventory = async (
|
|
75
|
+
plan: VocabularyRegradePlan,
|
|
76
|
+
rootDir = process.cwd()
|
|
77
|
+
): Promise<
|
|
78
|
+
TrailsResult<readonly VocabularyPreserveInventoryEntry[], ValidationError>
|
|
79
|
+
> => {
|
|
80
|
+
const lockPath = join(rootDir, 'trails.lock');
|
|
81
|
+
if (!existsSync(lockPath)) {
|
|
82
|
+
return Result.ok([]);
|
|
83
|
+
}
|
|
84
|
+
let input: unknown;
|
|
85
|
+
try {
|
|
86
|
+
input = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return Result.err(
|
|
89
|
+
new ValidationError(
|
|
90
|
+
'Unable to parse trails.lock for live API preserves.',
|
|
91
|
+
{
|
|
92
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
93
|
+
context: { path: lockPath },
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const parsed = lockShapeSchema.safeParse(input);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
return Result.err(
|
|
101
|
+
new ValidationError(
|
|
102
|
+
'trails.lock does not contain a compatible TopoGraph for live API preserves.',
|
|
103
|
+
{ context: { issues: parsed.error.issues, path: lockPath } }
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const sourceForms = deriveVocabularyFormProposals(plan).map(
|
|
108
|
+
(proposal) => proposal.from
|
|
109
|
+
);
|
|
110
|
+
return Result.ok(
|
|
111
|
+
liveApiValues(parsed.data)
|
|
112
|
+
.filter(({ value }) =>
|
|
113
|
+
sourceForms.some((source) => liveApiValueContainsSource(value, source))
|
|
114
|
+
)
|
|
115
|
+
.map(({ evidence, value }) => ({
|
|
116
|
+
disposition: 'preserve-current-live-api' as const,
|
|
117
|
+
evidence: [evidence],
|
|
118
|
+
pattern: escapeRegExp(value),
|
|
119
|
+
reason: 'current-live-topo-api',
|
|
120
|
+
source: 'derived-live-api' as const,
|
|
121
|
+
}))
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved Regrade plan artifact shape plus the slug, path, and hash helpers
|
|
3
|
+
* shared by the `regrade` trails and the consolidated Regrade history module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isPlainObject } from '@ontrails/core';
|
|
7
|
+
import {
|
|
8
|
+
regradePackageSourceExpectationSchema,
|
|
9
|
+
vocabularyRegradePlanSchema,
|
|
10
|
+
} from '@ontrails/regrade';
|
|
11
|
+
import type {
|
|
12
|
+
RegradeReport,
|
|
13
|
+
RegradeReportEntry,
|
|
14
|
+
VocabularyRegradePlan,
|
|
15
|
+
} from '@ontrails/regrade';
|
|
16
|
+
import { getGovernedVocabularyTransition } from '@ontrails/warden';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { join, normalize, posix, relative } from 'node:path';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
export const REGRADE_PLAN_SCHEMA_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
const regradePlanProvenanceValueSchema = z.enum(['authored', 'derived']);
|
|
24
|
+
|
|
25
|
+
const regradePlanDerivationSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
fileRenames: z.array(
|
|
28
|
+
z
|
|
29
|
+
.object({
|
|
30
|
+
evidence: z.array(z.string()),
|
|
31
|
+
from: z.string(),
|
|
32
|
+
provenance: z.literal('derived'),
|
|
33
|
+
status: z.literal('pending'),
|
|
34
|
+
to: z.string(),
|
|
35
|
+
})
|
|
36
|
+
.strict()
|
|
37
|
+
),
|
|
38
|
+
forms: z.array(
|
|
39
|
+
z
|
|
40
|
+
.object({
|
|
41
|
+
from: z.string(),
|
|
42
|
+
kind: z.enum(['review', 'safe-rewrite']),
|
|
43
|
+
provenance: regradePlanProvenanceValueSchema,
|
|
44
|
+
reason: z.string(),
|
|
45
|
+
source: z.enum([
|
|
46
|
+
'default-morphology',
|
|
47
|
+
'plan-defer',
|
|
48
|
+
'plan-override',
|
|
49
|
+
'seed',
|
|
50
|
+
]),
|
|
51
|
+
to: z.string().optional(),
|
|
52
|
+
})
|
|
53
|
+
.strict()
|
|
54
|
+
),
|
|
55
|
+
namespaces: z.array(
|
|
56
|
+
z
|
|
57
|
+
.object({
|
|
58
|
+
inScope: z.number().int().nonnegative(),
|
|
59
|
+
namespace: z.string(),
|
|
60
|
+
policyClassified: z.number().int().nonnegative(),
|
|
61
|
+
provenance: z.literal('derived'),
|
|
62
|
+
})
|
|
63
|
+
.strict()
|
|
64
|
+
),
|
|
65
|
+
preserves: z.array(
|
|
66
|
+
z
|
|
67
|
+
.object({
|
|
68
|
+
disposition: z.literal('preserve-current-live-api'),
|
|
69
|
+
evidence: z.array(z.string()),
|
|
70
|
+
pattern: z.string(),
|
|
71
|
+
provenance: z.literal('derived'),
|
|
72
|
+
reason: z.string().optional(),
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
),
|
|
76
|
+
referenceClosure: z
|
|
77
|
+
.object({
|
|
78
|
+
entries: z.array(
|
|
79
|
+
z
|
|
80
|
+
.object({
|
|
81
|
+
outcome: z.enum(['needs-review', 'rewrite']),
|
|
82
|
+
path: z.string(),
|
|
83
|
+
provenance: z.literal('derived'),
|
|
84
|
+
reason: z.string().optional(),
|
|
85
|
+
})
|
|
86
|
+
.strict()
|
|
87
|
+
),
|
|
88
|
+
issue: z.string().optional(),
|
|
89
|
+
moves: z.array(
|
|
90
|
+
z
|
|
91
|
+
.object({
|
|
92
|
+
deferred: z.number().int().nonnegative(),
|
|
93
|
+
from: z.string(),
|
|
94
|
+
historical: z.number().int().nonnegative(),
|
|
95
|
+
preserved: z.number().int().nonnegative(),
|
|
96
|
+
provenance: z.literal('derived'),
|
|
97
|
+
rewritten: z.number().int().nonnegative(),
|
|
98
|
+
skipped: z.number().int().nonnegative(),
|
|
99
|
+
to: z.string(),
|
|
100
|
+
})
|
|
101
|
+
.strict()
|
|
102
|
+
),
|
|
103
|
+
})
|
|
104
|
+
.strict(),
|
|
105
|
+
reviews: z.array(
|
|
106
|
+
z
|
|
107
|
+
.object({
|
|
108
|
+
evidence: z.array(
|
|
109
|
+
z
|
|
110
|
+
.object({
|
|
111
|
+
column: z.number().int().positive().optional(),
|
|
112
|
+
line: z.number().int().positive().optional(),
|
|
113
|
+
path: z.string(),
|
|
114
|
+
})
|
|
115
|
+
.strict()
|
|
116
|
+
),
|
|
117
|
+
provenance: z.literal('derived'),
|
|
118
|
+
reason: z.string(),
|
|
119
|
+
status: z.literal('pending'),
|
|
120
|
+
value: z.string(),
|
|
121
|
+
})
|
|
122
|
+
.strict()
|
|
123
|
+
),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
|
|
127
|
+
const classRegradePlanScopeSchema = z
|
|
128
|
+
.object({
|
|
129
|
+
exclude: z
|
|
130
|
+
.array(z.string())
|
|
131
|
+
.optional()
|
|
132
|
+
.describe('Root-relative path globs excluded from the class run'),
|
|
133
|
+
extensions: z
|
|
134
|
+
.array(z.string())
|
|
135
|
+
.optional()
|
|
136
|
+
.describe('Source file extensions scanned by the class run'),
|
|
137
|
+
include: z
|
|
138
|
+
.array(z.string())
|
|
139
|
+
.optional()
|
|
140
|
+
.describe('Root-relative path globs collected during the class run'),
|
|
141
|
+
})
|
|
142
|
+
.strict();
|
|
143
|
+
|
|
144
|
+
export { regradePackageSourceExpectationSchema };
|
|
145
|
+
|
|
146
|
+
export const persistentPackageSourcePathIssue = (value: {
|
|
147
|
+
readonly kind: 'published' | 'tarball';
|
|
148
|
+
readonly path?: string | undefined;
|
|
149
|
+
}): string | undefined => {
|
|
150
|
+
if (value.kind !== 'tarball') {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const path = value.path ?? '';
|
|
154
|
+
return path.length === 0 ||
|
|
155
|
+
posix.isAbsolute(path) ||
|
|
156
|
+
/^[A-Za-z]:/u.test(path) ||
|
|
157
|
+
path.startsWith('\\\\') ||
|
|
158
|
+
path.includes('\\') ||
|
|
159
|
+
path.includes('\u0000') ||
|
|
160
|
+
path === '.' ||
|
|
161
|
+
posix.normalize(path) !== path ||
|
|
162
|
+
path.startsWith('./') ||
|
|
163
|
+
path === '..' ||
|
|
164
|
+
path.startsWith('../') ||
|
|
165
|
+
path.includes('/../')
|
|
166
|
+
? 'Persisted Regrade package-source paths must be normalized root-relative POSIX paths.'
|
|
167
|
+
: undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const persistedPackageSourceExpectationSchema =
|
|
171
|
+
regradePackageSourceExpectationSchema.superRefine((value, ctx) => {
|
|
172
|
+
const issue = persistentPackageSourcePathIssue(value);
|
|
173
|
+
if (issue !== undefined) {
|
|
174
|
+
ctx.addIssue({ code: 'custom', message: issue, path: ['path'] });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A saved class-mode Regrade plan: which classes run, over what scope, and
|
|
180
|
+
* why. The parallel payload to {@link vocabularyRegradePlanSchema} — the
|
|
181
|
+
* `kind` discriminant keeps existing vocabulary plan artifacts
|
|
182
|
+
* byte-compatible.
|
|
183
|
+
*/
|
|
184
|
+
const classRegradePlanSchema = z.object({
|
|
185
|
+
classIds: z
|
|
186
|
+
.array(z.string().min(1))
|
|
187
|
+
.min(1)
|
|
188
|
+
.describe('Regrade class ids this plan runs'),
|
|
189
|
+
id: z.string().min(1).describe('Stable Regrade plan identifier'),
|
|
190
|
+
intent: z
|
|
191
|
+
.string()
|
|
192
|
+
.optional()
|
|
193
|
+
.describe('Human-authored migration intent for the class run'),
|
|
194
|
+
kind: z.literal('class').describe('Regrade plan kind'),
|
|
195
|
+
name: z
|
|
196
|
+
.string()
|
|
197
|
+
.min(1)
|
|
198
|
+
.optional()
|
|
199
|
+
.describe(
|
|
200
|
+
'Authored transition name; keys the saved plan and consolidated history filenames'
|
|
201
|
+
),
|
|
202
|
+
packageSource: persistedPackageSourceExpectationSchema
|
|
203
|
+
.optional()
|
|
204
|
+
.describe(
|
|
205
|
+
'Expected source for one directly declared downstream Trails package'
|
|
206
|
+
),
|
|
207
|
+
scope: classRegradePlanScopeSchema
|
|
208
|
+
.optional()
|
|
209
|
+
.describe('Collection scope for the class run'),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
export type ClassRegradePlan = z.output<typeof classRegradePlanSchema>;
|
|
213
|
+
|
|
214
|
+
const regradePlanBodySchema = z.discriminatedUnion('kind', [
|
|
215
|
+
vocabularyRegradePlanSchema,
|
|
216
|
+
classRegradePlanSchema,
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
export type RegradePlanBody = VocabularyRegradePlan | ClassRegradePlan;
|
|
220
|
+
|
|
221
|
+
const regradeExpansionCandidateSchema = z.union([
|
|
222
|
+
z.object({
|
|
223
|
+
evidence: z
|
|
224
|
+
.array(
|
|
225
|
+
z.object({
|
|
226
|
+
column: z.number().optional(),
|
|
227
|
+
detail: z.string().optional(),
|
|
228
|
+
line: z.number().optional(),
|
|
229
|
+
path: z.string(),
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
.default([]),
|
|
233
|
+
kind: z.enum(['file-rename', 'form', 'namespace', 'preserve']),
|
|
234
|
+
provenance: regradePlanProvenanceValueSchema.default('derived'),
|
|
235
|
+
reason: z.string().optional(),
|
|
236
|
+
status: z.enum(['pending', 'rejected']).default('pending'),
|
|
237
|
+
suggestedClassification: z.string(),
|
|
238
|
+
value: z.string(),
|
|
239
|
+
}),
|
|
240
|
+
z
|
|
241
|
+
.object({
|
|
242
|
+
detail: z.string().optional(),
|
|
243
|
+
path: z.string(),
|
|
244
|
+
status: z.enum(['pending', 'rejected']).default('pending'),
|
|
245
|
+
})
|
|
246
|
+
.transform((candidate) => ({
|
|
247
|
+
evidence: [
|
|
248
|
+
{
|
|
249
|
+
...(candidate.detail === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { detail: candidate.detail }),
|
|
252
|
+
path: candidate.path,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
kind: 'file-rename' as const,
|
|
256
|
+
provenance: 'derived' as const,
|
|
257
|
+
...(candidate.detail === undefined ? {} : { reason: candidate.detail }),
|
|
258
|
+
status: candidate.status,
|
|
259
|
+
suggestedClassification: 'legacy-path-candidate',
|
|
260
|
+
value: candidate.path,
|
|
261
|
+
})),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
export const regradePlanArtifactSchema = z
|
|
265
|
+
.object({
|
|
266
|
+
derivation: regradePlanDerivationSchema.optional(),
|
|
267
|
+
expansion: z
|
|
268
|
+
.object({
|
|
269
|
+
candidates: z.array(regradeExpansionCandidateSchema).default([]),
|
|
270
|
+
})
|
|
271
|
+
.optional(),
|
|
272
|
+
kind: z.literal('regrade-plan'),
|
|
273
|
+
path: z.string(),
|
|
274
|
+
plan: regradePlanBodySchema,
|
|
275
|
+
provenance: z.object({
|
|
276
|
+
fields: z.record(z.string(), regradePlanProvenanceValueSchema),
|
|
277
|
+
}),
|
|
278
|
+
schemaVersion: z.literal(REGRADE_PLAN_SCHEMA_VERSION),
|
|
279
|
+
sourceHash: z.string(),
|
|
280
|
+
transitionId: z
|
|
281
|
+
.string()
|
|
282
|
+
.min(1)
|
|
283
|
+
.optional()
|
|
284
|
+
.describe(
|
|
285
|
+
'Stable transition identity this plan re-runs; preserves the consolidated history spine'
|
|
286
|
+
),
|
|
287
|
+
})
|
|
288
|
+
.strict();
|
|
289
|
+
|
|
290
|
+
export interface RegradePlanExpansion {
|
|
291
|
+
readonly candidates: readonly {
|
|
292
|
+
readonly evidence: readonly {
|
|
293
|
+
readonly column?: number | undefined;
|
|
294
|
+
readonly detail?: string | undefined;
|
|
295
|
+
readonly line?: number | undefined;
|
|
296
|
+
readonly path: string;
|
|
297
|
+
}[];
|
|
298
|
+
readonly kind: 'file-rename' | 'form' | 'namespace' | 'preserve';
|
|
299
|
+
readonly provenance: 'authored' | 'derived';
|
|
300
|
+
readonly reason?: string | undefined;
|
|
301
|
+
readonly status: 'pending' | 'rejected';
|
|
302
|
+
readonly suggestedClassification: string;
|
|
303
|
+
readonly value: string;
|
|
304
|
+
}[];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface RegradePlanArtifact {
|
|
308
|
+
readonly derivation?: RegradePlanDerivation | undefined;
|
|
309
|
+
readonly expansion?: RegradePlanExpansion | undefined;
|
|
310
|
+
readonly kind: 'regrade-plan';
|
|
311
|
+
readonly path: string;
|
|
312
|
+
readonly plan: RegradePlanBody;
|
|
313
|
+
readonly provenance: {
|
|
314
|
+
readonly fields: Readonly<Record<string, 'authored' | 'derived'>>;
|
|
315
|
+
};
|
|
316
|
+
readonly schemaVersion: typeof REGRADE_PLAN_SCHEMA_VERSION;
|
|
317
|
+
readonly sourceHash: string;
|
|
318
|
+
readonly transitionId?: string | undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type RegradePlanDerivation = z.output<
|
|
322
|
+
typeof regradePlanDerivationSchema
|
|
323
|
+
>;
|
|
324
|
+
|
|
325
|
+
/** A plan artifact narrowed to a vocabulary plan body. */
|
|
326
|
+
export type VocabularyRegradePlanArtifact = RegradePlanArtifact & {
|
|
327
|
+
readonly plan: VocabularyRegradePlan;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const regradeSlugText = (text: string): string =>
|
|
331
|
+
text
|
|
332
|
+
.toLowerCase()
|
|
333
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
334
|
+
.replaceAll(/^-|-$/g, '');
|
|
335
|
+
|
|
336
|
+
const regradePlanSlug = (plan: Pick<VocabularyRegradePlan, 'from' | 'to'>) =>
|
|
337
|
+
regradeSlugText(`${plan.from}-to-${plan.to}`);
|
|
338
|
+
|
|
339
|
+
export const regradePlanSlugForBody = (plan: RegradePlanBody): string =>
|
|
340
|
+
plan.kind === 'class'
|
|
341
|
+
? regradeSlugText(plan.name ?? plan.classIds.join('-'))
|
|
342
|
+
: (() => {
|
|
343
|
+
const transition =
|
|
344
|
+
plan.id === undefined
|
|
345
|
+
? undefined
|
|
346
|
+
: getGovernedVocabularyTransition(plan.id);
|
|
347
|
+
return transition?.target.kind === 'classified'
|
|
348
|
+
? regradeSlugText(transition.id)
|
|
349
|
+
: regradePlanSlug(plan);
|
|
350
|
+
})();
|
|
351
|
+
|
|
352
|
+
const normalizeRelativePath = (path: string): string =>
|
|
353
|
+
normalize(path).replaceAll('\\', '/');
|
|
354
|
+
|
|
355
|
+
export const rootRelativePath = (
|
|
356
|
+
rootDir: string,
|
|
357
|
+
absolutePath: string
|
|
358
|
+
): string => normalizeRelativePath(relative(rootDir, absolutePath));
|
|
359
|
+
|
|
360
|
+
export const regradePlanDirectory = (rootDir: string): string =>
|
|
361
|
+
join(rootDir, '.trails', 'regrade');
|
|
362
|
+
|
|
363
|
+
export const regradePlanPathForPlan = (
|
|
364
|
+
rootDir: string,
|
|
365
|
+
plan: RegradePlanBody
|
|
366
|
+
): string =>
|
|
367
|
+
join(regradePlanDirectory(rootDir), `${regradePlanSlugForBody(plan)}.json`);
|
|
368
|
+
|
|
369
|
+
const sourceHashEntryFacts = (
|
|
370
|
+
entries: readonly RegradeReportEntry[]
|
|
371
|
+
): readonly Pick<
|
|
372
|
+
RegradeReportEntry,
|
|
373
|
+
'classId' | 'notes' | 'outcome' | 'path' | 'reason' | 'reviewDetails'
|
|
374
|
+
>[] =>
|
|
375
|
+
entries
|
|
376
|
+
.filter(
|
|
377
|
+
(entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
378
|
+
)
|
|
379
|
+
.map(({ classId, notes, outcome, path, reason, reviewDetails }) => ({
|
|
380
|
+
...(classId === undefined ? {} : { classId }),
|
|
381
|
+
...(notes === undefined ? {} : { notes }),
|
|
382
|
+
outcome,
|
|
383
|
+
path,
|
|
384
|
+
...(reason === undefined ? {} : { reason }),
|
|
385
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
const canonicalizeJsonValue = (value: unknown): unknown => {
|
|
389
|
+
if (Array.isArray(value)) {
|
|
390
|
+
return value.map((entry) => canonicalizeJsonValue(entry));
|
|
391
|
+
}
|
|
392
|
+
if (isPlainObject(value)) {
|
|
393
|
+
return Object.fromEntries(
|
|
394
|
+
Object.keys(value)
|
|
395
|
+
.toSorted()
|
|
396
|
+
.map((key) => [key, canonicalizeJsonValue(value[key])])
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* JSON.stringify with recursively sorted object keys so structurally equal
|
|
404
|
+
* values serialize identically regardless of key insertion order. Arrays keep
|
|
405
|
+
* their authored order.
|
|
406
|
+
*/
|
|
407
|
+
export const canonicalJsonStringify = (value: unknown): string =>
|
|
408
|
+
JSON.stringify(canonicalizeJsonValue(value));
|
|
409
|
+
|
|
410
|
+
export const isGeneratedRegradeArtifactPath = (path: string): boolean =>
|
|
411
|
+
/(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
|
|
412
|
+
|
|
413
|
+
const sourceHashLedgerFacts = (
|
|
414
|
+
report: RegradeReport,
|
|
415
|
+
policyMode: 'current' | 'legacy'
|
|
416
|
+
): unknown => {
|
|
417
|
+
const ledger = report.run?.ledger;
|
|
418
|
+
if (ledger === undefined) {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
// The active plan is written after its source hash and must not stale itself.
|
|
422
|
+
const occurrences = ledger.occurrences.filter(
|
|
423
|
+
(occurrence) =>
|
|
424
|
+
occurrence.scopeTier !== 'policy-classified' ||
|
|
425
|
+
(policyMode === 'current' &&
|
|
426
|
+
!isGeneratedRegradeArtifactPath(occurrence.path))
|
|
427
|
+
);
|
|
428
|
+
const forms = new Set(occurrences.map((occurrence) => occurrence.form));
|
|
429
|
+
return {
|
|
430
|
+
cycle: ledger.cycle,
|
|
431
|
+
forms: Object.fromEntries(
|
|
432
|
+
Object.entries(ledger.forms).filter(([form]) => forms.has(form))
|
|
433
|
+
),
|
|
434
|
+
occurrences,
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const regradeSourceHashFacts = (
|
|
439
|
+
report: RegradeReport,
|
|
440
|
+
policyMode: 'current' | 'legacy' = 'current',
|
|
441
|
+
fileEvidenceMode: 'current' | 'legacy' = 'current'
|
|
442
|
+
): unknown => ({
|
|
443
|
+
entries: sourceHashEntryFacts(report.entries),
|
|
444
|
+
fileRenames: report.run?.report.fileRenames?.map(
|
|
445
|
+
({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
|
|
446
|
+
deferred,
|
|
447
|
+
from,
|
|
448
|
+
...(fileEvidenceMode === 'current' ? { historical, preserved } : {}),
|
|
449
|
+
rewritten,
|
|
450
|
+
...(fileEvidenceMode === 'current' ? { skipped } : {}),
|
|
451
|
+
to,
|
|
452
|
+
})
|
|
453
|
+
),
|
|
454
|
+
ledger: sourceHashLedgerFacts(report, policyMode),
|
|
455
|
+
...(report.packageSource === undefined
|
|
456
|
+
? {}
|
|
457
|
+
: {
|
|
458
|
+
packageSource: {
|
|
459
|
+
artifactSha256: report.packageSource.artifactSha256,
|
|
460
|
+
contentSha256: report.packageSource.contentSha256,
|
|
461
|
+
declaredSpecifier: report.packageSource.declaredSpecifier,
|
|
462
|
+
kind: report.packageSource.kind,
|
|
463
|
+
name: report.packageSource.name,
|
|
464
|
+
version: report.packageSource.version,
|
|
465
|
+
},
|
|
466
|
+
}),
|
|
467
|
+
selectedClassIds: report.selectedClassIds,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const hashSerializedSourceFacts = (serialized: string): string =>
|
|
471
|
+
createHash('sha256').update(serialized).digest('hex');
|
|
472
|
+
|
|
473
|
+
export const regradeSourceHash = (report: RegradeReport): string =>
|
|
474
|
+
hashSerializedSourceFacts(
|
|
475
|
+
canonicalJsonStringify(regradeSourceHashFacts(report))
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
/** Match only the complete current source-evidence shape used by active plans. */
|
|
479
|
+
export const currentRegradeSourceHashMatches = (
|
|
480
|
+
stampedHash: string,
|
|
481
|
+
report: RegradeReport
|
|
482
|
+
): boolean => stampedHash === regradeSourceHash(report);
|
|
483
|
+
|
|
484
|
+
export const regradeSourceHashes = (
|
|
485
|
+
report: RegradeReport
|
|
486
|
+
): readonly string[] => {
|
|
487
|
+
const facts = [
|
|
488
|
+
regradeSourceHashFacts(report),
|
|
489
|
+
regradeSourceHashFacts(report, 'current', 'legacy'),
|
|
490
|
+
regradeSourceHashFacts(report, 'legacy'),
|
|
491
|
+
regradeSourceHashFacts(report, 'legacy', 'legacy'),
|
|
492
|
+
];
|
|
493
|
+
return [
|
|
494
|
+
...new Set(
|
|
495
|
+
facts.flatMap((value) => [
|
|
496
|
+
hashSerializedSourceFacts(canonicalJsonStringify(value)),
|
|
497
|
+
hashSerializedSourceFacts(JSON.stringify(value)),
|
|
498
|
+
])
|
|
499
|
+
),
|
|
500
|
+
];
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/** Match source evidence written by any supported historical hash shape. */
|
|
504
|
+
export const regradeSourceHashMatches = (
|
|
505
|
+
stampedHash: string,
|
|
506
|
+
report: RegradeReport
|
|
507
|
+
): boolean => regradeSourceHashes(report).includes(stampedHash);
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Canonical content hash of a resolved Regrade plan body — the authored
|
|
511
|
+
* migration intent. Stable across key insertion order; changes on any edit to
|
|
512
|
+
* the plan contents.
|
|
513
|
+
*/
|
|
514
|
+
export const regradePlanContentHash = (plan: RegradePlanBody): string =>
|
|
515
|
+
createHash('sha256').update(canonicalJsonStringify(plan)).digest('hex');
|