@ontrails/regrade 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 +606 -0
- package/package.json +40 -0
- package/src/downstream/ast-rewrite.ts +1031 -0
- package/src/downstream/collect.ts +240 -0
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/file-renames.ts +1677 -0
- package/src/downstream/package-source-artifact.ts +375 -0
- package/src/downstream/package-source-files.ts +195 -0
- package/src/downstream/package-source-manifest.ts +369 -0
- package/src/downstream/package-source.ts +237 -0
- package/src/downstream/report.ts +2085 -0
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary-registry.ts +195 -0
- package/src/downstream/vocabulary.ts +3094 -0
- package/src/history-receipt.ts +847 -0
- package/src/index.ts +170 -0
- package/src/literal-transform.ts +124 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const NONE_EXTENSION = '<none>';
|
|
4
|
+
|
|
5
|
+
/** Files grouped by extension for a Regrade scan. */
|
|
6
|
+
export interface RegradeScanExtensionBucket {
|
|
7
|
+
/** File extension, or `<none>` when the path has no extension. */
|
|
8
|
+
readonly extension: string;
|
|
9
|
+
/** Matched files in this extension bucket. */
|
|
10
|
+
readonly files: number;
|
|
11
|
+
/** Matched occurrences in this extension bucket, when occurrence data exists. */
|
|
12
|
+
readonly occurrences?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Files grouped by top-level path segment for a Regrade scan. */
|
|
16
|
+
export interface RegradeScanDirectoryBucket {
|
|
17
|
+
/** Top-level root-relative path segment, or `.` for root files. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
/** Matched files in this directory bucket. */
|
|
20
|
+
readonly files: number;
|
|
21
|
+
/** Matched occurrences in this directory bucket, when occurrence data exists. */
|
|
22
|
+
readonly occurrences?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Agent-facing inventory summary for a Regrade scan. */
|
|
26
|
+
export interface RegradeScanSummary {
|
|
27
|
+
/** File-level scan totals. */
|
|
28
|
+
readonly files: {
|
|
29
|
+
/** Files whose report outcome was rewrite or review. */
|
|
30
|
+
readonly matched: number;
|
|
31
|
+
/** Files inspected after collection and scope filters. */
|
|
32
|
+
readonly scanned: number;
|
|
33
|
+
/** Files or directories skipped before actionable matching. */
|
|
34
|
+
readonly skipped: number;
|
|
35
|
+
};
|
|
36
|
+
/** Matched files grouped by extension. */
|
|
37
|
+
readonly byExtension: readonly RegradeScanExtensionBucket[];
|
|
38
|
+
/** Matched files grouped by top-level directory/path segment. */
|
|
39
|
+
readonly byDirectory: readonly RegradeScanDirectoryBucket[];
|
|
40
|
+
/** Skipped entries grouped by reason. */
|
|
41
|
+
readonly skippedByReason: Readonly<Record<string, number>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RegradeScanSummaryInput {
|
|
45
|
+
readonly matchedPaths: readonly string[];
|
|
46
|
+
readonly occurrencePaths?: readonly string[];
|
|
47
|
+
readonly scanned: number;
|
|
48
|
+
readonly skipped: number;
|
|
49
|
+
readonly skippedByReason: Readonly<Record<string, number>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const extensionForPath = (path: string): string => {
|
|
53
|
+
const name = path.split('/').at(-1) ?? path;
|
|
54
|
+
const dot = name.lastIndexOf('.');
|
|
55
|
+
if (dot <= 0 || dot === name.length - 1) {
|
|
56
|
+
return NONE_EXTENSION;
|
|
57
|
+
}
|
|
58
|
+
return name.slice(dot);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const topLevelForPath = (path: string): string => {
|
|
62
|
+
const [segment] = path.split('/');
|
|
63
|
+
return segment === undefined || segment.length === 0 ? '.' : segment;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const countFiles = (
|
|
67
|
+
paths: readonly string[],
|
|
68
|
+
keyForPath: (path: string) => string
|
|
69
|
+
): Map<string, number> => {
|
|
70
|
+
const counts = new Map<string, number>();
|
|
71
|
+
for (const path of new Set(paths)) {
|
|
72
|
+
const key = keyForPath(path);
|
|
73
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
return counts;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const countOccurrences = (
|
|
79
|
+
paths: readonly string[] | undefined,
|
|
80
|
+
keyForPath: (path: string) => string
|
|
81
|
+
): Map<string, number> | undefined => {
|
|
82
|
+
if (paths === undefined) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const counts = new Map<string, number>();
|
|
86
|
+
for (const path of paths) {
|
|
87
|
+
const key = keyForPath(path);
|
|
88
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
return counts;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const sortBuckets = <T extends { readonly files: number }>(
|
|
94
|
+
left: T & { readonly key: string },
|
|
95
|
+
right: T & { readonly key: string }
|
|
96
|
+
): number => right.files - left.files || left.key.localeCompare(right.key);
|
|
97
|
+
|
|
98
|
+
export const buildRegradeScanSummary = ({
|
|
99
|
+
matchedPaths,
|
|
100
|
+
occurrencePaths,
|
|
101
|
+
scanned,
|
|
102
|
+
skipped,
|
|
103
|
+
skippedByReason,
|
|
104
|
+
}: RegradeScanSummaryInput): RegradeScanSummary => {
|
|
105
|
+
const extensionFiles = countFiles(matchedPaths, extensionForPath);
|
|
106
|
+
const extensionOccurrences = countOccurrences(
|
|
107
|
+
occurrencePaths,
|
|
108
|
+
extensionForPath
|
|
109
|
+
);
|
|
110
|
+
const directoryFiles = countFiles(matchedPaths, topLevelForPath);
|
|
111
|
+
const directoryOccurrences = countOccurrences(
|
|
112
|
+
occurrencePaths,
|
|
113
|
+
topLevelForPath
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
byDirectory: [...directoryFiles.entries()]
|
|
118
|
+
.map(([path, files]) => ({
|
|
119
|
+
files,
|
|
120
|
+
path,
|
|
121
|
+
...(directoryOccurrences === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { occurrences: directoryOccurrences.get(path) ?? 0 }),
|
|
124
|
+
key: path,
|
|
125
|
+
}))
|
|
126
|
+
.toSorted(sortBuckets)
|
|
127
|
+
.map(({ key: _key, ...bucket }) => bucket),
|
|
128
|
+
byExtension: [...extensionFiles.entries()]
|
|
129
|
+
.map(([extension, files]) => ({
|
|
130
|
+
extension,
|
|
131
|
+
files,
|
|
132
|
+
...(extensionOccurrences === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: { occurrences: extensionOccurrences.get(extension) ?? 0 }),
|
|
135
|
+
key: extension,
|
|
136
|
+
}))
|
|
137
|
+
.toSorted(sortBuckets)
|
|
138
|
+
.map(({ key: _key, ...bucket }) => bucket),
|
|
139
|
+
files: {
|
|
140
|
+
matched: new Set(matchedPaths).size,
|
|
141
|
+
scanned,
|
|
142
|
+
skipped,
|
|
143
|
+
},
|
|
144
|
+
skippedByReason,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const regradeScanSummaryOutput = z.object({
|
|
149
|
+
byDirectory: z
|
|
150
|
+
.array(
|
|
151
|
+
z.object({
|
|
152
|
+
files: z.number().describe('Matched files in this directory bucket'),
|
|
153
|
+
occurrences: z
|
|
154
|
+
.number()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe('Matched occurrences in this directory bucket'),
|
|
157
|
+
path: z
|
|
158
|
+
.string()
|
|
159
|
+
.describe(
|
|
160
|
+
'Top-level root-relative path segment, or . for root files'
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
.describe('Matched files grouped by top-level directory/path segment'),
|
|
165
|
+
byExtension: z
|
|
166
|
+
.array(
|
|
167
|
+
z.object({
|
|
168
|
+
extension: z.string().describe('File extension, or <none>'),
|
|
169
|
+
files: z.number().describe('Matched files in this extension bucket'),
|
|
170
|
+
occurrences: z
|
|
171
|
+
.number()
|
|
172
|
+
.optional()
|
|
173
|
+
.describe('Matched occurrences in this extension bucket'),
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
.describe('Matched files grouped by extension'),
|
|
177
|
+
files: z
|
|
178
|
+
.object({
|
|
179
|
+
matched: z
|
|
180
|
+
.number()
|
|
181
|
+
.describe('Files whose report outcome was rewrite or review'),
|
|
182
|
+
scanned: z
|
|
183
|
+
.number()
|
|
184
|
+
.describe('Files inspected after collection and scope filters'),
|
|
185
|
+
skipped: z
|
|
186
|
+
.number()
|
|
187
|
+
.describe('Files or directories skipped before actionable matching'),
|
|
188
|
+
})
|
|
189
|
+
.describe('File-level scan totals'),
|
|
190
|
+
skippedByReason: z
|
|
191
|
+
.record(z.string(), z.number())
|
|
192
|
+
.describe('Skipped entries grouped by reason'),
|
|
193
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { GovernedVocabularyTransition } from '@ontrails/warden';
|
|
2
|
+
import { listGovernedVocabularyTransitions } from '@ontrails/warden';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
VocabularyPreserveRule,
|
|
6
|
+
VocabularyRegradePlan,
|
|
7
|
+
} from './vocabulary.js';
|
|
8
|
+
|
|
9
|
+
const pluralizeVocabularyForm = (value: string): string =>
|
|
10
|
+
value.endsWith('s') || value.endsWith('x') || value.endsWith('ch')
|
|
11
|
+
? `${value}es`
|
|
12
|
+
: `${value}s`;
|
|
13
|
+
|
|
14
|
+
const defaultSourceForms = (value: string): readonly string[] =>
|
|
15
|
+
/^[A-Za-z]+$/.test(value) ? [value, pluralizeVocabularyForm(value)] : [value];
|
|
16
|
+
|
|
17
|
+
const preserveRulesFromTransition = (
|
|
18
|
+
transition: GovernedVocabularyTransition
|
|
19
|
+
): readonly VocabularyPreserveRule[] =>
|
|
20
|
+
transition.preserve.map((rule) => ({
|
|
21
|
+
...(rule.paths === undefined ? {} : { paths: rule.paths }),
|
|
22
|
+
pattern: rule.pattern,
|
|
23
|
+
reason: rule.reason,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const scopeFromTransition = (
|
|
27
|
+
transition: GovernedVocabularyTransition
|
|
28
|
+
): VocabularyRegradePlan['scope'] | undefined => {
|
|
29
|
+
const { scope } = transition;
|
|
30
|
+
if (scope === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...(scope.exclude === undefined ? {} : { exclude: [...scope.exclude] }),
|
|
35
|
+
...(scope.extensions === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { extensions: [...scope.extensions] }),
|
|
38
|
+
...(scope.ignoredDirectories === undefined
|
|
39
|
+
? {}
|
|
40
|
+
: { ignoredDirectories: [...scope.ignoredDirectories] }),
|
|
41
|
+
...(scope.include === undefined ? {} : { include: [...scope.include] }),
|
|
42
|
+
...(scope.policyClassified === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
policyClassified: scope.policyClassified.map((policy) => ({
|
|
46
|
+
...policy,
|
|
47
|
+
paths: [...policy.paths],
|
|
48
|
+
})),
|
|
49
|
+
}),
|
|
50
|
+
...(scope.teachingSurfaces === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: { teachingSurfaces: [...scope.teachingSurfaces] }),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const defaultFormsAreRegistrySafe = (
|
|
57
|
+
transition: GovernedVocabularyTransition
|
|
58
|
+
): boolean => {
|
|
59
|
+
if (transition.target.kind !== 'single') {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return defaultSourceForms(transition.from).every((form) => {
|
|
64
|
+
const replacement = transition.safeRewriteForms[form];
|
|
65
|
+
return replacement !== undefined && !transition.reviewForms.includes(form);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const classifiedTargetMatches = (
|
|
70
|
+
transition: GovernedVocabularyTransition,
|
|
71
|
+
to: string
|
|
72
|
+
): boolean =>
|
|
73
|
+
transition.target.kind === 'classified' &&
|
|
74
|
+
transition.target.options.some((option) => option.to === to);
|
|
75
|
+
|
|
76
|
+
const uniqueForms = (forms: readonly string[]): readonly string[] => [
|
|
77
|
+
...new Set(forms),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export const vocabularyRegradePlanFromTransition = (
|
|
81
|
+
transition: GovernedVocabularyTransition,
|
|
82
|
+
classifiedTarget?: string
|
|
83
|
+
): VocabularyRegradePlan | null => {
|
|
84
|
+
const isClassifiedPlan =
|
|
85
|
+
classifiedTarget !== undefined &&
|
|
86
|
+
classifiedTargetMatches(transition, classifiedTarget);
|
|
87
|
+
if (transition.target.kind === 'classified' && !isClassifiedPlan) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
transition.target.kind === 'single' &&
|
|
92
|
+
!defaultFormsAreRegistrySafe(transition)
|
|
93
|
+
) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const scope = scopeFromTransition(transition);
|
|
98
|
+
const to =
|
|
99
|
+
transition.target.kind === 'single'
|
|
100
|
+
? transition.target.to
|
|
101
|
+
: classifiedTarget;
|
|
102
|
+
if (to === undefined) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
caseSensitive: true,
|
|
107
|
+
deferForms: isClassifiedPlan
|
|
108
|
+
? uniqueForms([...transition.oldForms, ...transition.reviewForms])
|
|
109
|
+
: transition.reviewForms,
|
|
110
|
+
...(transition.fileRenames.length === 0
|
|
111
|
+
? {}
|
|
112
|
+
: {
|
|
113
|
+
fileRenames: transition.fileRenames.map((rename) => ({ ...rename })),
|
|
114
|
+
}),
|
|
115
|
+
from: transition.from,
|
|
116
|
+
id: transition.id,
|
|
117
|
+
intent: transition.intent,
|
|
118
|
+
kind: 'vocabulary',
|
|
119
|
+
...(isClassifiedPlan ? {} : { overrides: transition.safeRewriteForms }),
|
|
120
|
+
preserve: preserveRulesFromTransition(transition),
|
|
121
|
+
...(scope === undefined ? {} : { scope }),
|
|
122
|
+
to,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const vocabularyRegradePlanForInput = (
|
|
127
|
+
from: string,
|
|
128
|
+
to: string
|
|
129
|
+
): VocabularyRegradePlan | null => {
|
|
130
|
+
const transition = listGovernedVocabularyTransitions().find(
|
|
131
|
+
(candidate) => candidate.from === from
|
|
132
|
+
);
|
|
133
|
+
if (transition === undefined) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (transition.target.kind === 'single' && transition.target.to !== to) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return vocabularyRegradePlanFromTransition(transition, to);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const listVocabularyRegradePlansFromRegistry =
|
|
143
|
+
(): readonly VocabularyRegradePlan[] =>
|
|
144
|
+
listGovernedVocabularyTransitions()
|
|
145
|
+
.map((transition) => vocabularyRegradePlanFromTransition(transition))
|
|
146
|
+
.filter((plan): plan is VocabularyRegradePlan => plan !== null);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Derive conservative current-tree audit plans for every single-target
|
|
150
|
+
* governed transition. Transitions that are unsafe to rewrite mechanically
|
|
151
|
+
* still participate with every known form deferred to review.
|
|
152
|
+
*/
|
|
153
|
+
export const listVocabularyRegradeAuditPlansFromRegistry =
|
|
154
|
+
(): readonly VocabularyRegradePlan[] =>
|
|
155
|
+
listGovernedVocabularyTransitions().flatMap((transition) => {
|
|
156
|
+
if (
|
|
157
|
+
transition.status === 'planned' ||
|
|
158
|
+
transition.target.kind !== 'single'
|
|
159
|
+
) {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
const runnable = vocabularyRegradePlanFromTransition(transition);
|
|
163
|
+
if (runnable !== null) {
|
|
164
|
+
return [runnable];
|
|
165
|
+
}
|
|
166
|
+
const scope = scopeFromTransition(transition);
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
caseSensitive: true,
|
|
170
|
+
deferForms: uniqueForms([
|
|
171
|
+
...transition.oldForms,
|
|
172
|
+
...transition.reviewForms,
|
|
173
|
+
]),
|
|
174
|
+
from: transition.from,
|
|
175
|
+
id: transition.id,
|
|
176
|
+
intent: transition.intent,
|
|
177
|
+
kind: 'vocabulary' as const,
|
|
178
|
+
preserve: preserveRulesFromTransition(transition),
|
|
179
|
+
...(scope === undefined ? {} : { scope }),
|
|
180
|
+
to: transition.target.to,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export const vocabularyRegradeTransitionForInput = (
|
|
186
|
+
from: string,
|
|
187
|
+
to: string
|
|
188
|
+
): GovernedVocabularyTransition | undefined =>
|
|
189
|
+
listGovernedVocabularyTransitions().find(
|
|
190
|
+
(transition) =>
|
|
191
|
+
transition.from === from &&
|
|
192
|
+
(transition.target.kind === 'single'
|
|
193
|
+
? transition.target.to === to
|
|
194
|
+
: transition.target.options.some((option) => option.to === to))
|
|
195
|
+
);
|