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

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.
@@ -281,7 +281,7 @@ export const collectDownstreamSourcesOutput = z.object({
281
281
  export const collectDownstreamSourcesTrail = trail(
282
282
  'regrade.downstream.collect',
283
283
  {
284
- blaze: (input) => {
284
+ implementation: (input) => {
285
285
  const collection = collectDownstreamSources(input.root, {
286
286
  ...(input.extensions === undefined
287
287
  ? {}
@@ -18,7 +18,7 @@
18
18
  * emitted on a guess.
19
19
  */
20
20
 
21
- import type { AstNode, SourceEdit } from '@ontrails/warden/ast';
21
+ import type { AstNode, SourceEdit } from '@ontrails/source';
22
22
  import {
23
23
  applySourceEdits,
24
24
  createSourceEdit,
@@ -60,7 +60,7 @@ import {
60
60
  parseWithDiagnostics,
61
61
  validateSourceEdits,
62
62
  walkWithParents,
63
- } from '@ontrails/warden/ast';
63
+ } from '@ontrails/source';
64
64
  import type { WardenRule } from '@ontrails/warden';
65
65
  import {
66
66
  getWardenRuleMetadata,
@@ -17,7 +17,8 @@ import {
17
17
  loadProjectWardenRules,
18
18
  wardenRules,
19
19
  } from '@ontrails/warden';
20
- import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
21
+ import { dirname, join, relative, resolve } from 'node:path';
21
22
  import { z } from 'zod';
22
23
 
23
24
  import {
@@ -79,6 +80,16 @@ export interface RegradeClassContext {
79
80
  readonly path: string;
80
81
  /** Absolute path on disk, when the caller has one. */
81
82
  readonly absolutePath?: string;
83
+ /** Nearest owning package facts, when filesystem collection found a manifest. */
84
+ readonly package?: {
85
+ readonly dependencies: readonly string[];
86
+ /** Runtime-visible dependency declarations (dependencies, optional, peer). */
87
+ readonly runtimeDependencies?: readonly string[];
88
+ readonly manifestState?: 'invalid' | 'valid';
89
+ readonly name?: string;
90
+ /** Root-relative POSIX manifest path. */
91
+ readonly path: string;
92
+ };
82
93
  }
83
94
 
84
95
  /** Files a regrade class knows how to inspect. */
@@ -966,6 +977,7 @@ const buildRegradeEvaluation = (params: {
966
977
  readonly path: string;
967
978
  readonly source: string;
968
979
  readonly absolutePath?: string;
980
+ readonly package?: RegradeClassContext['package'];
969
981
  }[];
970
982
  readonly skipped: readonly SkippedSource[];
971
983
  readonly classes: readonly RegradeClass[];
@@ -987,6 +999,7 @@ const buildRegradeEvaluation = (params: {
987
999
  ...(file.absolutePath === undefined
988
1000
  ? {}
989
1001
  : { absolutePath: file.absolutePath }),
1002
+ ...(file.package === undefined ? {} : { package: file.package }),
990
1003
  path: file.path,
991
1004
  },
992
1005
  selected,
@@ -1055,6 +1068,7 @@ export const buildRegradeReport = (params: {
1055
1068
  readonly path: string;
1056
1069
  readonly source: string;
1057
1070
  readonly absolutePath?: string;
1071
+ readonly package?: RegradeClassContext['package'];
1058
1072
  }[];
1059
1073
  readonly skipped: readonly SkippedSource[];
1060
1074
  readonly classes: readonly RegradeClass[];
@@ -1127,6 +1141,121 @@ const canReadDownstreamRoot = (root: string): boolean => {
1127
1141
  }
1128
1142
  };
1129
1143
 
1144
+ const PACKAGE_DEPENDENCY_FIELDS = [
1145
+ 'dependencies',
1146
+ 'devDependencies',
1147
+ 'optionalDependencies',
1148
+ 'peerDependencies',
1149
+ ] as const;
1150
+
1151
+ const RUNTIME_PACKAGE_DEPENDENCY_FIELDS = [
1152
+ 'dependencies',
1153
+ 'optionalDependencies',
1154
+ 'peerDependencies',
1155
+ ] as const;
1156
+
1157
+ const dependencyNames = (
1158
+ manifest: Readonly<Record<string, unknown>>,
1159
+ fields: readonly (typeof PACKAGE_DEPENDENCY_FIELDS)[number][]
1160
+ ): readonly string[] =>
1161
+ fields.flatMap((field) => {
1162
+ const value = manifest[field];
1163
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1164
+ ? Object.keys(value)
1165
+ : [];
1166
+ });
1167
+
1168
+ const packageContextFor = (
1169
+ root: string,
1170
+ absolutePath: string,
1171
+ cache: Map<string, RegradeClassContext['package'] | undefined>
1172
+ ): RegradeClassContext['package'] | undefined => {
1173
+ const absoluteRoot = resolve(root);
1174
+ let current = dirname(absolutePath);
1175
+ const visited: string[] = [];
1176
+ while (true) {
1177
+ if (cache.has(current)) {
1178
+ const cached = cache.get(current);
1179
+ for (const directory of visited) {
1180
+ cache.set(directory, cached);
1181
+ }
1182
+ return cached;
1183
+ }
1184
+ visited.push(current);
1185
+ const fromRoot = relative(absoluteRoot, current);
1186
+ if (fromRoot.startsWith('..') || resolve(current) !== current) {
1187
+ break;
1188
+ }
1189
+ const manifestPath = join(current, 'package.json');
1190
+ if (existsSync(manifestPath)) {
1191
+ const manifestRelativePath = relative(
1192
+ absoluteRoot,
1193
+ manifestPath
1194
+ ).replaceAll('\\', '/');
1195
+ try {
1196
+ const parsed = JSON.parse(
1197
+ readFileSync(manifestPath, 'utf8')
1198
+ ) as unknown;
1199
+ if (
1200
+ typeof parsed !== 'object' ||
1201
+ parsed === null ||
1202
+ Array.isArray(parsed)
1203
+ ) {
1204
+ const invalidContext = {
1205
+ dependencies: [],
1206
+ manifestState: 'invalid' as const,
1207
+ path: manifestRelativePath,
1208
+ };
1209
+ for (const directory of visited) {
1210
+ cache.set(directory, invalidContext);
1211
+ }
1212
+ return invalidContext;
1213
+ }
1214
+ const manifest = parsed as Record<string, unknown>;
1215
+ const dependencies = dependencyNames(
1216
+ manifest,
1217
+ PACKAGE_DEPENDENCY_FIELDS
1218
+ );
1219
+ const runtimeDependencies = dependencyNames(
1220
+ manifest,
1221
+ RUNTIME_PACKAGE_DEPENDENCY_FIELDS
1222
+ );
1223
+ const packageContext = {
1224
+ dependencies: [...new Set(dependencies)].toSorted(),
1225
+ manifestState: 'valid' as const,
1226
+ ...(typeof manifest['name'] === 'string'
1227
+ ? { name: manifest['name'] }
1228
+ : {}),
1229
+ path: manifestRelativePath,
1230
+ runtimeDependencies: [...new Set(runtimeDependencies)].toSorted(),
1231
+ };
1232
+ for (const directory of visited) {
1233
+ cache.set(directory, packageContext);
1234
+ }
1235
+ return packageContext;
1236
+ } catch {
1237
+ const invalidContext = {
1238
+ dependencies: [],
1239
+ manifestState: 'invalid' as const,
1240
+ path: manifestRelativePath,
1241
+ };
1242
+ for (const directory of visited) {
1243
+ cache.set(directory, invalidContext);
1244
+ }
1245
+ return invalidContext;
1246
+ }
1247
+ }
1248
+ if (current === absoluteRoot) {
1249
+ break;
1250
+ }
1251
+ current = dirname(current);
1252
+ }
1253
+ for (const directory of visited) {
1254
+ cache.set(directory, undefined);
1255
+ }
1256
+ return undefined;
1257
+ };
1258
+
1130
1259
  const runRegradeEvaluation = (params: {
1131
1260
  readonly root: string;
1132
1261
  readonly classes: readonly RegradeClass[];
@@ -1169,10 +1298,20 @@ const runRegradeEvaluation = (params: {
1169
1298
 
1170
1299
  const files: { absolutePath: string; path: string; source: string }[] = [];
1171
1300
  const skipped: SkippedSource[] = [...collected.skipped];
1301
+ const packageContextCache = new Map<
1302
+ string,
1303
+ RegradeClassContext['package'] | undefined
1304
+ >();
1172
1305
  for (const file of collected.files) {
1173
1306
  try {
1307
+ const packageContext = packageContextFor(
1308
+ params.root,
1309
+ file.absolutePath,
1310
+ packageContextCache
1311
+ );
1174
1312
  files.push({
1175
1313
  absolutePath: file.absolutePath,
1314
+ ...(packageContext === undefined ? {} : { package: packageContext }),
1176
1315
  path: file.path,
1177
1316
  source: readFileSync(file.absolutePath, 'utf8'),
1178
1317
  });
@@ -11,6 +11,9 @@ const pluralizeVocabularyForm = (value: string): string =>
11
11
  ? `${value}es`
12
12
  : `${value}s`;
13
13
 
14
+ const defaultSourceForms = (value: string): readonly string[] =>
15
+ /^[A-Za-z]+$/.test(value) ? [value, pluralizeVocabularyForm(value)] : [value];
16
+
14
17
  const preserveRulesFromTransition = (
15
18
  transition: GovernedVocabularyTransition
16
19
  ): readonly VocabularyPreserveRule[] =>
@@ -44,12 +47,7 @@ const defaultFormsAreRegistrySafe = (
44
47
  return false;
45
48
  }
46
49
 
47
- const defaultForms = [
48
- transition.from,
49
- pluralizeVocabularyForm(transition.from),
50
- ];
51
-
52
- return defaultForms.every((form) => {
50
+ return defaultSourceForms(transition.from).every((form) => {
53
51
  const replacement = transition.safeRewriteForms[form];
54
52
  return replacement !== undefined && !transition.reviewForms.includes(form);
55
53
  });
@@ -6,7 +6,14 @@ import {
6
6
  matchesAnyPathGlob,
7
7
  } from '@ontrails/core';
8
8
  import { createHash } from 'node:crypto';
9
- import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
9
+ import {
10
+ dirname,
11
+ extname,
12
+ isAbsolute,
13
+ join,
14
+ normalize,
15
+ relative,
16
+ } from 'node:path';
10
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
18
  import { z } from 'zod';
12
19
 
@@ -213,30 +220,56 @@ const vocabularyDispositionCounts = (
213
220
  );
214
221
  };
215
222
 
216
- const isVocabularyTokenCharacter = (value: string): boolean =>
217
- /[A-Za-z0-9_$-]/.test(value);
223
+ const isVocabularyTokenCharacter = (
224
+ value: string,
225
+ routeLike: boolean
226
+ ): boolean => /[A-Za-z0-9_$-]/.test(value) || (routeLike && value === '/');
227
+
228
+ const isVocabularyTokenCharacterAt = (
229
+ source: string,
230
+ index: number,
231
+ routeLike: boolean
232
+ ): boolean => {
233
+ if (index < 0 || index >= source.length) {
234
+ return false;
235
+ }
236
+ const value = source.at(index) ?? '';
237
+ if (isVocabularyTokenCharacter(value, routeLike)) {
238
+ return true;
239
+ }
240
+ if (!routeLike || value !== '.') {
241
+ return false;
242
+ }
243
+ return (
244
+ isVocabularyTokenCharacter(source.at(index - 1) ?? '', false) &&
245
+ isVocabularyTokenCharacter(source.at(index + 1) ?? '', false)
246
+ );
247
+ };
218
248
 
219
249
  const hasWordBoundary = (
220
250
  source: string,
221
251
  start: number,
222
- end: number
252
+ end: number,
253
+ form: string
223
254
  ): boolean => {
224
- const before = start === 0 ? '' : (source.at(start - 1) ?? '');
225
- const after = end >= source.length ? '' : (source.at(end) ?? '');
255
+ const routeLike = form.includes('/');
226
256
  return (
227
- !isVocabularyTokenCharacter(before) && !isVocabularyTokenCharacter(after)
257
+ !isVocabularyTokenCharacterAt(source, start - 1, routeLike) &&
258
+ !isVocabularyTokenCharacterAt(source, end, routeLike)
228
259
  );
229
260
  };
230
261
 
231
262
  const expandVocabularyNeighborSpan = (
232
263
  source: string,
233
264
  start: number,
234
- end: number
265
+ end: number,
266
+ form: string
235
267
  ): { readonly end: number; readonly start: number } => {
268
+ const routeLike = form.includes('/');
236
269
  let expandedStart = start;
237
270
  while (
238
271
  expandedStart > 0 &&
239
- isVocabularyTokenCharacter(source.at(expandedStart - 1) ?? '')
272
+ isVocabularyTokenCharacterAt(source, expandedStart - 1, routeLike)
240
273
  ) {
241
274
  expandedStart -= 1;
242
275
  }
@@ -244,7 +277,7 @@ const expandVocabularyNeighborSpan = (
244
277
  let expandedEnd = end;
245
278
  while (
246
279
  expandedEnd < source.length &&
247
- isVocabularyTokenCharacter(source.at(expandedEnd) ?? '')
280
+ isVocabularyTokenCharacterAt(source, expandedEnd, routeLike)
248
281
  ) {
249
282
  expandedEnd += 1;
250
283
  }
@@ -288,6 +321,24 @@ const contextDetailsForOffset = (
288
321
  const isMarkdownPath = (path: string): boolean =>
289
322
  path.endsWith('.md') || path.endsWith('.mdx');
290
323
 
324
+ const packageRouteCodeExtensions = new Set([
325
+ '.cjs',
326
+ '.cts',
327
+ '.js',
328
+ '.jsx',
329
+ '.mjs',
330
+ '.mts',
331
+ '.ts',
332
+ '.tsx',
333
+ ]);
334
+
335
+ const isPackageRouteCodeOccurrence = (path: string, form: string): boolean =>
336
+ packageRouteCodeExtensions.has(extname(path)) &&
337
+ /^@[^/]+\/[^/]+(?:\/.*)?$/.test(form);
338
+
339
+ const isPackageManifestPath = (path: string): boolean =>
340
+ path === 'package.json' || path.endsWith('/package.json');
341
+
291
342
  const sourceLineBoundsForOffset = (
292
343
  source: string,
293
344
  start: number,
@@ -478,11 +529,13 @@ const defaultDeferredVocabularyForms = (from: string): readonly string[] => {
478
529
  ]).filter((form) => form !== from && form !== pluralize(from));
479
530
  };
480
531
 
481
- const defaultVocabularyForms = (from: string, to: string) =>
482
- new Map<string, string>([
483
- [from, to],
484
- [pluralize(from), pluralize(to)],
485
- ]);
532
+ const defaultVocabularyForms = (from: string, to: string) => {
533
+ const forms = new Map<string, string>([[from, to]]);
534
+ if (isSimpleVocabularyWord(from) && isSimpleVocabularyWord(to)) {
535
+ forms.set(pluralize(from), pluralize(to));
536
+ }
537
+ return forms;
538
+ };
486
539
 
487
540
  const normalizedOverrideEntries = (
488
541
  overrides: Readonly<Record<string, string>> | undefined
@@ -806,7 +859,7 @@ const exactDeferredFormOccurrencesForFile = (
806
859
  formIdentityForPlan(plan, form)
807
860
  );
808
861
  if (
809
- !hasWordBoundary(file.source, start, end) ||
862
+ !hasWordBoundary(file.source, start, end, form) ||
810
863
  occurrenceOverlaps(occurrences, start, end) ||
811
864
  (!isAuthoredDefer && occurrenceOverlaps(targetFormSpans, start, end))
812
865
  ) {
@@ -840,7 +893,7 @@ const targetFormSpansForFile = (
840
893
  const start = match.index ?? 0;
841
894
  const end = start + match[0].length;
842
895
  if (
843
- !hasWordBoundary(file.source, start, end) ||
896
+ !hasWordBoundary(file.source, start, end, form) ||
844
897
  occurrenceOverlaps(spans, start, end)
845
898
  ) {
846
899
  continue;
@@ -869,7 +922,7 @@ const occurrencesForFile = (
869
922
  const start = match.index ?? 0;
870
923
  const end = start + match[0].length;
871
924
  if (
872
- !hasWordBoundary(file.source, start, end) ||
925
+ !hasWordBoundary(file.source, start, end, form) ||
873
926
  occurrenceOverlaps(deferredOccurrences, start, end)
874
927
  ) {
875
928
  continue;
@@ -889,10 +942,27 @@ const occurrencesForFile = (
889
942
  };
890
943
  const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
891
944
  const markdownCodeContext = isMarkdownCodeContext(file, start, end);
892
- const verdict = capturedVocabularyVerdict(
945
+ const packageRouteCodeContext = isPackageRouteCodeOccurrence(
946
+ file.path,
947
+ form
948
+ );
949
+ let verdict = capturedVocabularyVerdict(
893
950
  preserveRule,
894
951
  markdownCodeContext
895
952
  );
953
+ const packageManifestContext = isPackageManifestPath(file.path);
954
+ if (
955
+ preserveRule === undefined &&
956
+ (packageRouteCodeContext || packageManifestContext)
957
+ ) {
958
+ verdict = 'deferred';
959
+ }
960
+ let capturedReason = 'captured-form';
961
+ if (packageRouteCodeContext) {
962
+ capturedReason = 'package-route-ast-required';
963
+ } else if (packageManifestContext) {
964
+ capturedReason = 'package-manifest-structured-edit-required';
965
+ }
896
966
  candidates.push({
897
967
  absolutePath: baseOccurrence.absolutePath,
898
968
  column: baseOccurrence.column,
@@ -909,9 +979,12 @@ const occurrencesForFile = (
909
979
  reason: vocabularyOccurrenceReason(
910
980
  preserveRule,
911
981
  markdownCodeContext,
912
- 'captured-form'
982
+ capturedReason
913
983
  ),
914
- ...(preserveRule === undefined && !markdownCodeContext
984
+ ...(preserveRule === undefined &&
985
+ !markdownCodeContext &&
986
+ !packageRouteCodeContext &&
987
+ !packageManifestContext
915
988
  ? { replacement: preserveCase(match[0], replacement) }
916
989
  : {}),
917
990
  start: baseOccurrence.start,
@@ -972,13 +1045,14 @@ const deferredOccurrencesForFile = (
972
1045
  for (const match of file.source.matchAll(pattern)) {
973
1046
  const matchStart = match.index ?? 0;
974
1047
  const matchEnd = matchStart + match[0].length;
975
- if (hasWordBoundary(file.source, matchStart, matchEnd)) {
1048
+ if (hasWordBoundary(file.source, matchStart, matchEnd, form)) {
976
1049
  continue;
977
1050
  }
978
1051
  const { end, start } = expandVocabularyNeighborSpan(
979
1052
  file.source,
980
1053
  matchStart,
981
- matchEnd
1054
+ matchEnd,
1055
+ form
982
1056
  );
983
1057
  const matchedForm = file.source.slice(start, end);
984
1058
  const lowerMatchedForm = matchedForm.toLowerCase();
@@ -16,10 +16,10 @@ const childInput = z.object({
16
16
  });
17
17
 
18
18
  /**
19
- * Parent input schema with its raw-to-blaze transform attached.
19
+ * Parent input schema with its raw-to-implementation transform attached.
20
20
  *
21
21
  * Naming the transformed schema lets examples and tests reference both its raw
22
- * pre-transform input (`z.input`) and its post-transform blaze input
22
+ * pre-transform input (`z.input`) and its post-transform implementation input
23
23
  * (`z.infer`) without restating either shape by hand.
24
24
  */
25
25
  const regradeTransformInputToChild = regradeTransformInput.transform(
@@ -32,15 +32,15 @@ const regradeTransformInputToChild = regradeTransformInput.transform(
32
32
  * Raw, pre-transform input accepted by {@link regradeTransformInputToChild}.
33
33
  *
34
34
  * Trail examples and `testExamples()` feed this shape through validation; the
35
- * Zod transform then projects it into the blaze input.
35
+ * Zod transform then projects it into the implementation input.
36
36
  */
37
37
  type RegradeTransformRawInput = z.input<typeof regradeTransformInputToChild>;
38
38
 
39
39
  /**
40
- * Post-transform blaze input shape — the trail's inferred input type `I`.
40
+ * Post-transform implementation input shape — the trail's inferred input type `I`.
41
41
  *
42
42
  * TRL-842: With a `.transform()` input schema, the trail's inferred input type
43
- * `I` is the transform OUTPUT (the blaze input), while examples and
43
+ * `I` is the transform OUTPUT (the implementation input), while examples and
44
44
  * `testExamples()` validate the raw pre-transform INPUT. Those two shapes are
45
45
  * disjoint, so an authored example must carry raw input typed as the
46
46
  * post-transform shape. A framework-level fix would thread a separate
@@ -49,7 +49,9 @@ type RegradeTransformRawInput = z.input<typeof regradeTransformInputToChild>;
49
49
  * then the divergence is captured by the two named types here and exercised by
50
50
  * the runtime validation test in `__tests__/literal-transform.test.ts`.
51
51
  */
52
- type RegradeTransformBlazeInput = z.infer<typeof regradeTransformInputToChild>;
52
+ type RegradeTransformImplementationInput = z.infer<
53
+ typeof regradeTransformInputToChild
54
+ >;
53
55
 
54
56
  /**
55
57
  * Raw example input, statically checked as valid pre-transform input. If the
@@ -63,7 +65,7 @@ const codeStringExampleInput: RegradeTransformRawInput = {
63
65
  export const normalizeExportConstTrail = trail(
64
66
  'regrade.literal.normalize-export-const',
65
67
  {
66
- blaze: (input) => {
68
+ implementation: (input) => {
67
69
  const nextSource = input.source.replaceAll('export const', 'export let');
68
70
  return Result.ok({
69
71
  changed: nextSource !== input.source,
@@ -81,17 +83,6 @@ export const normalizeExportConstTrail = trail(
81
83
  );
82
84
 
83
85
  export const literalRegradeTrail = trail('regrade.literal.run', {
84
- blaze: async (input, ctx) => {
85
- if (!ctx.compose) {
86
- return Result.err(
87
- new InternalError(
88
- 'Literal Regrade tracer requires compose-capable execution.'
89
- )
90
- );
91
- }
92
- const blazeInput = input as RegradeTransformBlazeInput;
93
- return await ctx.compose(normalizeExportConstTrail, blazeInput.child);
94
- },
95
86
  composes: [normalizeExportConstTrail],
96
87
  examples: [
97
88
  {
@@ -103,11 +94,26 @@ export const literalRegradeTrail = trail('regrade.literal.run', {
103
94
  // TRL-842: author the raw pre-transform value (validated as
104
95
  // RegradeTransformRawInput) and widen through `unknown` to the trail's
105
96
  // inferred post-transform input type. The runtime still parses raw input;
106
- // see RegradeTransformBlazeInput for why the two shapes diverge.
107
- input: codeStringExampleInput as unknown as RegradeTransformBlazeInput,
97
+ // see RegradeTransformImplementationInput for why the two shapes diverge.
98
+ input:
99
+ codeStringExampleInput as unknown as RegradeTransformImplementationInput,
108
100
  name: 'code-string fixture',
109
101
  },
110
102
  ],
103
+ implementation: async (input, ctx) => {
104
+ if (!ctx.compose) {
105
+ return Result.err(
106
+ new InternalError(
107
+ 'Literal Regrade tracer requires compose-capable execution.'
108
+ )
109
+ );
110
+ }
111
+ const implementationInput = input as RegradeTransformImplementationInput;
112
+ return await ctx.compose(
113
+ normalizeExportConstTrail,
114
+ implementationInput.child
115
+ );
116
+ },
111
117
  input: regradeTransformInputToChild,
112
118
  output: regradeTransformOutput,
113
119
  });