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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,98 @@
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 preserveRulesFromTransition = (
15
+ transition: GovernedVocabularyTransition
16
+ ): readonly VocabularyPreserveRule[] =>
17
+ transition.preserve.map((rule) => ({
18
+ ...(rule.paths === undefined ? {} : { paths: rule.paths }),
19
+ pattern: rule.pattern,
20
+ reason: rule.reason,
21
+ }));
22
+
23
+ const scopeFromTransition = (
24
+ transition: GovernedVocabularyTransition
25
+ ): VocabularyRegradePlan['scope'] | undefined => {
26
+ const { scope } = transition;
27
+ if (scope === undefined) {
28
+ return undefined;
29
+ }
30
+ return {
31
+ ...(scope.exclude === undefined ? {} : { exclude: scope.exclude }),
32
+ ...(scope.extensions === undefined ? {} : { extensions: scope.extensions }),
33
+ ...(scope.ignoredDirectories === undefined
34
+ ? {}
35
+ : { ignoredDirectories: scope.ignoredDirectories }),
36
+ ...(scope.include === undefined ? {} : { include: scope.include }),
37
+ };
38
+ };
39
+
40
+ const defaultFormsAreRegistrySafe = (
41
+ transition: GovernedVocabularyTransition
42
+ ): boolean => {
43
+ if (transition.target.kind !== 'single') {
44
+ return false;
45
+ }
46
+
47
+ const defaultForms = [
48
+ transition.from,
49
+ pluralizeVocabularyForm(transition.from),
50
+ ];
51
+
52
+ return defaultForms.every((form) => {
53
+ const replacement = transition.safeRewriteForms[form];
54
+ return replacement !== undefined && !transition.reviewForms.includes(form);
55
+ });
56
+ };
57
+
58
+ export const vocabularyRegradePlanFromTransition = (
59
+ transition: GovernedVocabularyTransition
60
+ ): VocabularyRegradePlan | null => {
61
+ if (
62
+ transition.target.kind !== 'single' ||
63
+ !defaultFormsAreRegistrySafe(transition)
64
+ ) {
65
+ return null;
66
+ }
67
+
68
+ const scope = scopeFromTransition(transition);
69
+ return {
70
+ caseSensitive: true,
71
+ deferForms: transition.reviewForms,
72
+ from: transition.from,
73
+ id: transition.id,
74
+ intent: transition.intent,
75
+ kind: 'vocabulary',
76
+ overrides: transition.safeRewriteForms,
77
+ preserve: preserveRulesFromTransition(transition),
78
+ ...(scope === undefined ? {} : { scope }),
79
+ to: transition.target.to,
80
+ };
81
+ };
82
+
83
+ export const listVocabularyRegradePlansFromRegistry =
84
+ (): readonly VocabularyRegradePlan[] =>
85
+ listGovernedVocabularyTransitions()
86
+ .map(vocabularyRegradePlanFromTransition)
87
+ .filter((plan): plan is VocabularyRegradePlan => plan !== null);
88
+
89
+ export const vocabularyRegradeTransitionForInput = (
90
+ from: string,
91
+ to: string
92
+ ): GovernedVocabularyTransition | undefined =>
93
+ listGovernedVocabularyTransitions().find(
94
+ (transition) =>
95
+ transition.from === from &&
96
+ transition.target.kind === 'single' &&
97
+ transition.target.to === to
98
+ );