@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.
@@ -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
+ );