atmn 1.1.25 → 2.0.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.
Files changed (117) hide show
  1. package/README.md +120 -0
  2. package/dist/bin.js +23102 -0
  3. package/dist/index.js +2906 -0
  4. package/dist/tsconfig.tsbuildinfo +1 -1
  5. package/package.json +27 -74
  6. package/src/actions/api/callApi.ts +280 -0
  7. package/src/actions/api/registerApiCommands.ts +121 -0
  8. package/src/actions/env/fetchOrgInfo.ts +32 -0
  9. package/src/actions/env/types/orgInfo.ts +18 -0
  10. package/src/actions/env.ts +94 -0
  11. package/src/actions/init/runInit.ts +401 -0
  12. package/src/actions/login/keyless.ts +135 -0
  13. package/src/actions/login.ts +144 -0
  14. package/src/actions/pull/appendPlanVersionFixture.ts +373 -0
  15. package/src/actions/pull/applyPreview.ts +553 -0
  16. package/src/actions/pull/applySettingsPreview.ts +114 -0
  17. package/src/actions/pull/changedFixtureKeys.ts +88 -0
  18. package/src/actions/pull/listSourceFiles.ts +29 -0
  19. package/src/actions/pull/locateFixture.ts +78 -0
  20. package/src/actions/pull/resolveCollectionTarget.ts +198 -0
  21. package/src/actions/pull/rewriteConfig.ts +57 -0
  22. package/src/actions/pull/scaffoldConfig.ts +118 -0
  23. package/src/actions/pull.ts +399 -0
  24. package/src/actions/push/backfillInternalIds.ts +394 -0
  25. package/src/actions/push/deprecatedFields.ts +55 -0
  26. package/src/actions/push.ts +294 -0
  27. package/src/actions/reset/runReset.ts +58 -0
  28. package/src/actions/sandbox/createSandbox.ts +83 -0
  29. package/src/actions/sandbox/deleteSandbox.ts +91 -0
  30. package/src/actions/sandbox/listSandboxes.ts +28 -0
  31. package/src/actions/sandbox/types/sandboxClient.ts +16 -0
  32. package/src/actions/sandbox/useSandbox.ts +157 -0
  33. package/src/actions/sandbox/withSandboxScopeHint.ts +27 -0
  34. package/src/actions/skills/skills.ts +246 -0
  35. package/src/auth/announceAuthorizationUrl.ts +26 -0
  36. package/src/auth/browser/openSystemBrowser.ts +7 -0
  37. package/src/auth/browser/tryOpenBrowser.ts +17 -0
  38. package/src/auth/browser/watchLauncher.ts +46 -0
  39. package/src/auth/buildAuthorizationUrl.ts +36 -0
  40. package/src/auth/callbackPages.ts +126 -0
  41. package/src/auth/createOrgApiKeys.ts +46 -0
  42. package/src/auth/keyless.ts +119 -0
  43. package/src/auth/oauthConfig.ts +63 -0
  44. package/src/auth/runOAuthFlow.ts +230 -0
  45. package/src/auth/types/browserOpener.ts +5 -0
  46. package/src/auth/types/impersonationTokens.ts +23 -0
  47. package/src/auth/types/oauthTokens.ts +13 -0
  48. package/src/auth/types/orgApiKeys.ts +6 -0
  49. package/src/bin.ts +9 -0
  50. package/src/cli.ts +648 -0
  51. package/src/config/configPackageName.ts +9 -0
  52. package/src/config/legacyConfig.ts +25 -0
  53. package/src/config/loadConfig.ts +231 -0
  54. package/src/env/assertSandboxTarget.ts +20 -0
  55. package/src/env/loadEnv.ts +184 -0
  56. package/src/env/resolveTarget.ts +134 -0
  57. package/src/env/sandboxKeyName.ts +18 -0
  58. package/src/generated/apiRoutes.ts +3787 -0
  59. package/src/generated/client.ts +51564 -0
  60. package/src/generated/emit.ts +1163 -0
  61. package/src/generated/emitRuntime.ts +522 -0
  62. package/src/generated/features.ts +146 -0
  63. package/src/generated/labels.ts +29 -0
  64. package/src/generated/licenses.ts +287 -0
  65. package/src/generated/lintRules.ts +2207 -0
  66. package/src/generated/lintRuntime.ts +865 -0
  67. package/src/generated/plans.ts +1628 -0
  68. package/src/generated/referralPrograms.ts +22 -0
  69. package/src/generated/rewards.ts +58 -0
  70. package/src/generated/settings.ts +21 -0
  71. package/src/generated/skills.ts +305 -0
  72. package/src/generated/variants.ts +934 -0
  73. package/src/generated/wire.ts +334 -0
  74. package/src/http/autumnFetch.ts +30 -0
  75. package/src/index.ts +20 -0
  76. package/src/project/chooseConfigDir.ts +41 -0
  77. package/src/project/resolveProject.ts +115 -0
  78. package/src/project/rootMarker.ts +40 -0
  79. package/src/prompt/prompt.ts +186 -0
  80. package/src/prompt/select.ts +162 -0
  81. package/src/render/renderEnv.ts +77 -0
  82. package/src/render/renderPreview.ts +945 -0
  83. package/src/render/renderSandboxes.ts +92 -0
  84. package/src/render/stripTerminalControls.ts +19 -0
  85. package/src/repo/findRepoRoot.ts +79 -0
  86. package/src/surgery/appendPropertyEdit.ts +67 -0
  87. package/src/surgery/appendToArray.ts +87 -0
  88. package/src/surgery/appendToBinding.ts +19 -0
  89. package/src/surgery/appendToCollection.ts +40 -0
  90. package/src/surgery/appendToFixtureArray.ts +71 -0
  91. package/src/surgery/arrayBinding.ts +30 -0
  92. package/src/surgery/deleteFixtureLiteral.ts +81 -0
  93. package/src/surgery/deleteReference.ts +48 -0
  94. package/src/surgery/ensureBuilderImport.ts +65 -0
  95. package/src/surgery/findFixture.ts +238 -0
  96. package/src/surgery/fixtureEdit.ts +156 -0
  97. package/src/surgery/fixtureLocation.ts +32 -0
  98. package/src/surgery/insertCollection.ts +86 -0
  99. package/src/surgery/insertFirstProperty.ts +73 -0
  100. package/src/surgery/patchFixtureProperty.ts +152 -0
  101. package/src/surgery/patchSingletonProperty.ts +221 -0
  102. package/src/surgery/replaceFixture.ts +28 -0
  103. package/src/surgery/setFixtureProperty.ts +55 -0
  104. package/src/surgery/staticFixtureRule.ts +48 -0
  105. package/src/version.ts +5 -0
  106. package/dist/cli.js +0 -146296
  107. package/dist/compose/index.js +0 -122
  108. package/dist/src/compose/builders/builderFunctions.d.ts +0 -84
  109. package/dist/src/compose/builders/rewardFunctions.d.ts +0 -5
  110. package/dist/src/compose/builders/variantFunctions.d.ts +0 -2
  111. package/dist/src/compose/index.d.ts +0 -19
  112. package/dist/src/compose/models/featureModels.d.ts +0 -262
  113. package/dist/src/compose/models/index.d.ts +0 -3
  114. package/dist/src/compose/models/planModels.d.ts +0 -562
  115. package/dist/src/compose/models/rewardModels.d.ts +0 -52
  116. package/dist/src/compose/models/variantModels.d.ts +0 -34
  117. package/readme.md +0 -186
@@ -0,0 +1,65 @@
1
+ import { Lang, parse } from "@ast-grep/napi";
2
+ import { configPackageName } from "../config/configPackageName";
3
+
4
+ const NAMED_IMPORT = /^import\s+\{([^}]*)\}\s*from\s*["']([^"']+)["']/;
5
+ const GENERATED_MODULE = /\/generated\/[A-Za-z]+$/;
6
+
7
+ /**
8
+ * A file that gains an inline `plan({...})` must import `plan`. The specifier
9
+ * follows the file's own builder imports: `<x>/generated/wire` → `<x>/generated/plans`,
10
+ * the package → the same package import; a file with neither gets the package.
11
+ */
12
+ export const ensureBuilderImport = ({
13
+ source,
14
+ builder,
15
+ collection,
16
+ }: {
17
+ source: string;
18
+ builder: string;
19
+ collection: string;
20
+ }): string => {
21
+ const root = parse(Lang.TypeScript, source).root();
22
+ const statements = root.findAll({ rule: { kind: "import_statement" } });
23
+ const parsed = statements.flatMap((statement) => {
24
+ const match = NAMED_IMPORT.exec(statement.text());
25
+ return match
26
+ ? [
27
+ {
28
+ statement,
29
+ names: match[1].split(",").map((n) => n.trim()),
30
+ specifier: match[2],
31
+ },
32
+ ]
33
+ : [];
34
+ });
35
+ const imported = parsed.some((entry) =>
36
+ entry.names.some(
37
+ (name) => name === builder || name.startsWith(`${builder} as `),
38
+ ),
39
+ );
40
+ if (imported) return source;
41
+
42
+ const packageName = configPackageName();
43
+ const packageImport = parsed.find((entry) => entry.specifier === packageName);
44
+ if (packageImport !== undefined) {
45
+ const text = packageImport.statement.text();
46
+ const widened = text.replace(/\{\s*/, `{ ${builder}, `);
47
+ return root.commitEdits([
48
+ {
49
+ startPos: packageImport.statement.range().start.index,
50
+ endPos: packageImport.statement.range().end.index,
51
+ insertedText: widened,
52
+ },
53
+ ]);
54
+ }
55
+ const generated = parsed.find((entry) =>
56
+ GENERATED_MODULE.test(entry.specifier),
57
+ );
58
+ const line =
59
+ generated === undefined
60
+ ? `import { ${builder} } from "${packageName}";\n`
61
+ : `import { ${builder} } from "${generated.specifier.replace(GENERATED_MODULE, `/generated/${collection}`)}";\n`;
62
+ const anchor = generated?.statement ?? statements[0];
63
+ const at = anchor === undefined ? 0 : anchor.range().end.index + 1;
64
+ return `${source.slice(0, at)}${line}${source.slice(at)}`;
65
+ };
@@ -0,0 +1,238 @@
1
+ import type { SgNode } from "@ast-grep/napi";
2
+ import { Lang, parse } from "@ast-grep/napi";
3
+ import { containsDynamicValue } from "./fixtureEdit";
4
+
5
+ /** A property the matched literal must hold; an absent one may count as a default. */
6
+ export type FixtureConstraint = {
7
+ field: string;
8
+ equals: string;
9
+ absentMeans?: string;
10
+ };
11
+
12
+ /** The one parent literal a nested shape may stand under: the base row that
13
+ * states this stable id, or, when it states none, this public id and constraints. */
14
+ export type ParentFixture = {
15
+ internalId?: string;
16
+ idField: string;
17
+ id: string;
18
+ where?: FixtureConstraint[];
19
+ };
20
+
21
+ /**
22
+ * Where a fixture literal may stand: the first argument of a builder call
23
+ * (`plan({...})`), or an object element of one of a builder call's array
24
+ * properties (`plan({ variants: [{...}] })`), optionally under one `parent`.
25
+ */
26
+ export type FixtureShape =
27
+ | string
28
+ | { parentBuilder: string; arrayProperty: string; parent?: ParentFixture };
29
+
30
+ /** The object literal a found fixture node holds: the node itself, or a call's first argument. */
31
+ export const fixtureObjectOf = (node: SgNode): SgNode | null => {
32
+ if (node.kind() === "object") return node;
33
+ const object = node.field("arguments")?.namedChildren()[0];
34
+ return object !== undefined && object.kind() === "object" ? object : null;
35
+ };
36
+
37
+ const builderCalls = ({
38
+ root,
39
+ builder,
40
+ }: {
41
+ root: SgNode;
42
+ builder: string;
43
+ }): SgNode[] =>
44
+ root
45
+ .findAll({ rule: { kind: "call_expression" } })
46
+ .filter((call) => call.field("function")?.text() === builder);
47
+
48
+ /** Candidate nodes for the shape, each paired with the literal it holds. */
49
+ const candidateFixtures = ({
50
+ root,
51
+ builder,
52
+ }: {
53
+ root: SgNode;
54
+ builder: FixtureShape;
55
+ }): { node: SgNode; object: SgNode }[] => {
56
+ if (typeof builder === "string") {
57
+ return builderCalls({ root, builder }).flatMap((call) => {
58
+ const object = fixtureObjectOf(call);
59
+ return object === null ? [] : [{ node: call, object }];
60
+ });
61
+ }
62
+ return builderCalls({ root, builder: builder.parentBuilder }).flatMap(
63
+ (call) => {
64
+ const parent = fixtureObjectOf(call);
65
+ if (parent === null) return [];
66
+ if (
67
+ builder.parent !== undefined &&
68
+ !isParentFixture({ object: parent, parent: builder.parent })
69
+ )
70
+ return [];
71
+ const array = topLevelPairValue({
72
+ object: parent,
73
+ key: builder.arrayProperty,
74
+ });
75
+ if (array === null || array.kind() !== "array") return [];
76
+ // An element is a bare literal or a builder call around one:
77
+ // `variants: [{...}]` and `variants: [variant({...})]` both count.
78
+ return array.namedChildren().flatMap((element) => {
79
+ const object = fixtureObjectOf(element);
80
+ return object === null ? [] : [{ node: element, object }];
81
+ });
82
+ },
83
+ );
84
+ };
85
+
86
+ export const findFixture = ({
87
+ source,
88
+ builder,
89
+ idField,
90
+ id,
91
+ where,
92
+ allowDynamic = false,
93
+ }: {
94
+ source: string;
95
+ builder: FixtureShape;
96
+ idField: string;
97
+ id: string;
98
+ where?: FixtureConstraint[];
99
+ /** Match a literal built from spreads or calls too: to name it, not to edit it. */
100
+ allowDynamic?: boolean;
101
+ }): SgNode | null => {
102
+ const root = parse(Lang.TypeScript, source).root();
103
+ // A rule walk rather than a pattern: a pattern misses an object whose id
104
+ // pair follows a spread, and the fixture must be found to be refused.
105
+ for (const { node, object } of candidateFixtures({ root, builder })) {
106
+ const idValue = topLevelPairValue({ object, key: idField });
107
+ if (idValue === null || stringLiteralValue(idValue) !== id) continue;
108
+ if (!allowDynamic && containsDynamicValue(object)) continue;
109
+ if (where !== undefined && !satisfiesFixtureConstraints({ object, where }))
110
+ continue;
111
+ return node;
112
+ }
113
+ return null;
114
+ };
115
+
116
+ const SIMPLE_ESCAPES: Record<string, string> = {
117
+ n: "\n",
118
+ t: "\t",
119
+ r: "\r",
120
+ b: "\b",
121
+ f: "\f",
122
+ v: "\v",
123
+ "0": "\0",
124
+ };
125
+
126
+ const CODE_POINT_ESCAPE = /^u\{([0-9a-fA-F]{1,6})\}/;
127
+ const CODE_UNIT_ESCAPE = /^u([0-9a-fA-F]{4})/;
128
+ const HEX_ESCAPE = /^x([0-9a-fA-F]{2})/;
129
+
130
+ /** A quoted string's body as the runtime reads it, so `'a\\'b'` and `"a'b"`
131
+ * name one fixture. */
132
+ const decodeStringBody = (body: string): string => {
133
+ let decoded = "";
134
+ let index = 0;
135
+ while (index < body.length) {
136
+ const char = body[index] ?? "";
137
+ if (char !== "\\") {
138
+ decoded += char;
139
+ index += 1;
140
+ continue;
141
+ }
142
+ const rest = body.slice(index + 1);
143
+ const numeric =
144
+ CODE_POINT_ESCAPE.exec(rest) ??
145
+ CODE_UNIT_ESCAPE.exec(rest) ??
146
+ HEX_ESCAPE.exec(rest);
147
+ if (numeric !== null) {
148
+ decoded += String.fromCodePoint(Number.parseInt(numeric[1] ?? "0", 16));
149
+ index += 1 + numeric[0].length;
150
+ continue;
151
+ }
152
+ const escaped = rest[0];
153
+ if (escaped === undefined) return decoded;
154
+ decoded += SIMPLE_ESCAPES[escaped] ?? escaped;
155
+ index += 2;
156
+ }
157
+ return decoded;
158
+ };
159
+
160
+ /**
161
+ * The text a string node stands for, so `'pro'` and `"pro"` name one fixture.
162
+ * Null for anything else — a template literal is a computed value, not an id.
163
+ */
164
+ const stringLiteralValue = (node: SgNode): string | null => {
165
+ if (node.kind() !== "string") return null;
166
+ const text = node.text();
167
+ const quote = text[0];
168
+ if (quote !== '"' && quote !== "'") return null;
169
+ if (text.length < 2 || !text.endsWith(quote)) return null;
170
+ return decodeStringBody(text.slice(1, -1));
171
+ };
172
+
173
+ /** The value of the object's own `key: value` member, ignoring nested objects. */
174
+ const topLevelPairValue = ({
175
+ object,
176
+ key,
177
+ }: {
178
+ object: SgNode;
179
+ key: string;
180
+ }): SgNode | null => {
181
+ for (const member of object.children()) {
182
+ if (member.kind() !== "pair") continue;
183
+ const [name, value] = member.namedChildren();
184
+ if (name?.text() === key && value !== undefined) return value;
185
+ }
186
+ return null;
187
+ };
188
+
189
+ /** A stated stable id decides on its own; only a literal without one is
190
+ * matched by its public id, so two versions sharing an id stay apart. */
191
+ const isParentFixture = ({
192
+ object,
193
+ parent,
194
+ }: {
195
+ object: SgNode;
196
+ parent: ParentFixture;
197
+ }): boolean => {
198
+ const statedInternalId = topLevelPairValue({ object, key: "internalId" });
199
+ const literalInternalId =
200
+ statedInternalId === null ? null : stringLiteralValue(statedInternalId);
201
+ if (literalInternalId !== null)
202
+ return literalInternalId === parent.internalId;
203
+ const idValue = topLevelPairValue({ object, key: parent.idField });
204
+ if (idValue === null || stringLiteralValue(idValue) !== parent.id)
205
+ return false;
206
+ return (
207
+ parent.where === undefined ||
208
+ satisfiesFixtureConstraints({ object, where: parent.where })
209
+ );
210
+ };
211
+
212
+ const satisfiesFixtureConstraints = ({
213
+ object,
214
+ where,
215
+ }: {
216
+ object: SgNode;
217
+ where: FixtureConstraint[];
218
+ }): boolean =>
219
+ where.every((constraint) => satisfiesConstraint({ object, constraint }));
220
+
221
+ const satisfiesConstraint = ({
222
+ object,
223
+ constraint,
224
+ }: {
225
+ object: SgNode;
226
+ constraint: FixtureConstraint;
227
+ }): boolean => {
228
+ const pair = object
229
+ .children()
230
+ .find(
231
+ (child) =>
232
+ child.kind() === "pair" &&
233
+ child.namedChildren()[0]?.text() === constraint.field,
234
+ );
235
+ if (pair === undefined) return constraint.absentMeans === constraint.equals;
236
+ const value = pair.namedChildren()[1];
237
+ return value !== undefined && stringLiteralValue(value) === constraint.equals;
238
+ };
@@ -0,0 +1,156 @@
1
+ import type { Edit, SgNode } from "@ast-grep/napi";
2
+
3
+ // Kinds that make an object literal's value dynamic, so a fixture built from it
4
+ // cannot be rewritten as bytes. `property_identifier` (keys) is a different kind.
5
+ export const dynamicValueKinds = [
6
+ "identifier",
7
+ "member_expression",
8
+ "call_expression",
9
+ "spread_element",
10
+ "computed_property_name",
11
+ ] as const;
12
+
13
+ /** An array element naming another fixture (`variants: [proAnnual]`, or
14
+ * `[...annualVariants]`) is a reference, not a computed value. */
15
+ const isFixtureReference = (element: SgNode): boolean =>
16
+ element.kind() === "identifier" ||
17
+ (element.kind() === "spread_element" &&
18
+ element.namedChildren()[0]?.kind() === "identifier");
19
+
20
+ export const containsDynamicValue = (node: SgNode): boolean => {
21
+ if (dynamicValueKinds.some((dynamicKind) => node.kind() === dynamicKind))
22
+ return true;
23
+ for (const child of node.children()) {
24
+ if (node.kind() === "array" && isFixtureReference(child)) continue;
25
+ if (containsDynamicValue(child)) return true;
26
+ }
27
+ return false;
28
+ };
29
+
30
+ export const lineStartOf = (source: string, index: number): number =>
31
+ index <= 0 ? 0 : source.lastIndexOf("\n", index - 1) + 1;
32
+
33
+ export const lineEndOf = (source: string, index: number): number => {
34
+ const newline = source.indexOf("\n", index);
35
+ return newline === -1 ? source.length : newline;
36
+ };
37
+
38
+ /** End of the line containing `index`, inclusive of the newline when present. */
39
+ export const lineEndInclusive = (source: string, index: number): number => {
40
+ const lineEnd = lineEndOf(source, index);
41
+ return lineEnd < source.length ? lineEnd + 1 : lineEnd;
42
+ };
43
+
44
+ export const leadingIndentOfLine = (source: string, index: number): string => {
45
+ const lineStart = lineStartOf(source, index);
46
+ const match = /^\s*/.exec(source.slice(lineStart));
47
+ return match === null ? "" : match[0];
48
+ };
49
+
50
+ export const importStatementOf = (node: SgNode): SgNode | null => {
51
+ for (const ancestor of node.ancestors()) {
52
+ if (ancestor.kind() === "import_statement") return ancestor;
53
+ }
54
+ return null;
55
+ };
56
+
57
+ /** Remove a node together with its line: leading indentation through the newline. */
58
+ export const removeLineEdit = ({
59
+ source,
60
+ node,
61
+ }: {
62
+ source: string;
63
+ node: SgNode;
64
+ }): Edit => ({
65
+ startPos: lineStartOf(source, node.range().start.index),
66
+ endPos: lineEndInclusive(source, node.range().end.index),
67
+ insertedText: "",
68
+ });
69
+
70
+ /**
71
+ * Remove an element from an array: its line when it stands alone (trailing comma
72
+ * included), a collapse to `[]` when it is the sole element, or just the element
73
+ * plus one adjacent comma when it shares a line with siblings.
74
+ */
75
+ export const removeArrayElementEdits = ({
76
+ source,
77
+ element,
78
+ }: {
79
+ source: string;
80
+ element: SgNode;
81
+ }): Array<Edit> | null => {
82
+ const parent = element.parent();
83
+ if (parent === null || parent.kind() !== "array") return null;
84
+ if (parent.namedChildren().length === 1) {
85
+ return [
86
+ {
87
+ startPos: parent.range().start.index,
88
+ endPos: parent.range().end.index,
89
+ insertedText: "[]",
90
+ },
91
+ ];
92
+ }
93
+ const start = element.range().start.index;
94
+ const end = element.range().end.index;
95
+ const lineStart = lineStartOf(source, start);
96
+ const lineEnd = lineEndOf(source, end);
97
+ const after = source.slice(end, lineEnd);
98
+ const commaIndex = after.indexOf(",");
99
+ const hasTrailingComma =
100
+ commaIndex !== -1 && after.slice(0, commaIndex).trim() === "";
101
+ const restAfterComma = hasTrailingComma ? after.slice(commaIndex + 1) : after;
102
+ const standsAlone =
103
+ source.slice(lineStart, start).trim() === "" &&
104
+ restAfterComma.trim() === "";
105
+ if (standsAlone) {
106
+ return [
107
+ {
108
+ startPos: lineStart,
109
+ endPos: lineEndInclusive(source, end),
110
+ insertedText: "",
111
+ },
112
+ ];
113
+ }
114
+ if (hasTrailingComma) {
115
+ // Inline siblings: take the comma and the spacing before the next one.
116
+ const afterComma = end + commaIndex + 1;
117
+ const spacing = /^[ \t]*/.exec(source.slice(afterComma))?.[0].length ?? 0;
118
+ return [
119
+ { startPos: start, endPos: afterComma + spacing, insertedText: "" },
120
+ ];
121
+ }
122
+ const commaBefore = source.lastIndexOf(",", start);
123
+ return [
124
+ {
125
+ startPos: commaBefore === -1 ? start : commaBefore,
126
+ endPos: end,
127
+ insertedText: "",
128
+ },
129
+ ];
130
+ };
131
+
132
+ /** Remove an import specifier plus one adjacent comma, keeping the statement. */
133
+ export const removeSpecifierEdit = ({
134
+ source,
135
+ specifier,
136
+ }: {
137
+ source: string;
138
+ specifier: SgNode;
139
+ }): Edit => {
140
+ const start = specifier.range().start.index;
141
+ const end = specifier.range().end.index;
142
+ const after = source.slice(end);
143
+ const commaAfter = after.indexOf(",");
144
+ if (commaAfter !== -1 && after.slice(0, commaAfter).trim() === "") {
145
+ // Take the space after the comma too, or `{ a, b }` becomes `{ b }`.
146
+ const afterComma = end + commaAfter + 1;
147
+ const spaces = /^[ \t]*/.exec(source.slice(afterComma))?.[0].length ?? 0;
148
+ return { startPos: start, endPos: afterComma + spaces, insertedText: "" };
149
+ }
150
+ const commaBefore = source.lastIndexOf(",", start);
151
+ return {
152
+ startPos: commaBefore === -1 ? start : commaBefore,
153
+ endPos: end,
154
+ insertedText: "",
155
+ };
156
+ };
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, relative } from "node:path";
3
+ import { listSourceFiles } from "../actions/pull/listSourceFiles";
4
+ import { locateFixture } from "../actions/pull/locateFixture";
5
+
6
+ /** The one place the CLI answers "where does this fixture live". */
7
+ export const fixtureLocation = ({
8
+ configPath,
9
+ builder,
10
+ idField,
11
+ id,
12
+ }: {
13
+ configPath: string;
14
+ builder: string;
15
+ idField: string;
16
+ id: string;
17
+ }): { file: string; line: number } | null => {
18
+ const directory = dirname(configPath);
19
+ const files = new Map<string, string>();
20
+ files.set(configPath, readFileSync(configPath, "utf8"));
21
+ for (const file of listSourceFiles({ directory })) {
22
+ if (!files.has(file)) files.set(file, readFileSync(file, "utf8"));
23
+ }
24
+
25
+ const located = locateFixture({ configPath, files, builder, idField, id });
26
+ if (located === null) return null;
27
+
28
+ return {
29
+ file: relative(directory, located.file),
30
+ line: located.node.range().start.line + 1,
31
+ };
32
+ };
@@ -0,0 +1,86 @@
1
+ import { Lang, parse } from "@ast-grep/napi";
2
+ import { leadingIndentOfLine, lineStartOf } from "./fixtureEdit";
3
+
4
+ /** Identifiers spread into the `atmn({...})` object: `atmn({ ...shared })` → ["shared"]. */
5
+ export const rootSpreadNames = ({ source }: { source: string }): string[] => {
6
+ const root = parse(Lang.TypeScript, source).root();
7
+ const call = root.find("atmn($ARG)");
8
+ const object = call?.getMatch("ARG");
9
+ if (object === null || object === undefined || object.kind() !== "object")
10
+ return [];
11
+ return object
12
+ .children()
13
+ .filter((child) => child.kind() === "spread_element")
14
+ .map((child) => child.namedChildren()[0]?.text() ?? child.text());
15
+ };
16
+
17
+ export const insertCollection = ({
18
+ source,
19
+ collection,
20
+ }: {
21
+ source: string;
22
+ collection: string;
23
+ }): string | null => {
24
+ const root = parse(Lang.TypeScript, source).root();
25
+ const call = root.find("atmn($ARG)");
26
+ if (call === null) return null;
27
+ const object = call.getMatch("ARG");
28
+ if (object === null || object.kind() !== "object") return null;
29
+
30
+ const pairs = object.children().filter((child) => child.kind() === "pair");
31
+ const named = object
32
+ .children()
33
+ .some((child) =>
34
+ child.kind() === "pair"
35
+ ? child.namedChildren()[0]?.text() === collection
36
+ : child.kind() === "shorthand_property_identifier" &&
37
+ child.text() === collection,
38
+ );
39
+ if (named) return source;
40
+
41
+ if (pairs.length === 0) {
42
+ const callLineIndent = leadingIndentOfLine(
43
+ source,
44
+ call.range().start.index,
45
+ );
46
+ const indent = `${callLineIndent}\t`;
47
+ return root.commitEdits([
48
+ {
49
+ startPos: object.range().start.index,
50
+ endPos: object.range().end.index,
51
+ insertedText: `{\n${indent}${collection}: [],\n${callLineIndent}}`,
52
+ },
53
+ ]);
54
+ }
55
+
56
+ const last = pairs[pairs.length - 1];
57
+ const lastEnd = last.range().end.index;
58
+ const after = source.slice(lastEnd);
59
+ const commaAfter = after.indexOf(",");
60
+ const hasTrailingComma =
61
+ commaAfter !== -1 && after.slice(0, commaAfter).trim() === "";
62
+ const insertAt = hasTrailingComma ? lastEnd + commaAfter + 1 : lastEnd;
63
+ const missingComma = hasTrailingComma ? "" : ",";
64
+ // A one-line object keeps its shape: the key goes inline after the last pair.
65
+ const spansLines = object.text().includes("\n");
66
+ if (!spansLines) {
67
+ return root.commitEdits([
68
+ {
69
+ startPos: insertAt,
70
+ endPos: insertAt,
71
+ insertedText: `${missingComma} ${collection}: [],`,
72
+ },
73
+ ]);
74
+ }
75
+ const indent = source.slice(
76
+ lineStartOf(source, last.range().start.index),
77
+ last.range().start.index,
78
+ );
79
+ return root.commitEdits([
80
+ {
81
+ startPos: insertAt,
82
+ endPos: insertAt,
83
+ insertedText: `${missingComma}\n${indent}${collection}: [],`,
84
+ },
85
+ ]);
86
+ };
@@ -0,0 +1,73 @@
1
+ import { appendPropertyEdit, holdsSpread } from "./appendPropertyEdit";
2
+ import {
3
+ type FixtureConstraint,
4
+ type FixtureShape,
5
+ findFixture,
6
+ fixtureObjectOf,
7
+ } from "./findFixture";
8
+ import { lineStartOf } from "./fixtureEdit";
9
+
10
+ export const insertFirstProperty = ({
11
+ source,
12
+ builder,
13
+ idField,
14
+ id,
15
+ where,
16
+ property,
17
+ }: {
18
+ source: string;
19
+ builder: FixtureShape;
20
+ idField: string;
21
+ id: string;
22
+ where?: FixtureConstraint[];
23
+ property: string;
24
+ }): string | null => {
25
+ // A splice keeps every other byte, so a literal the pull rewriter refuses
26
+ // (it names another fixture) still takes one new property.
27
+ const call = findFixture({
28
+ source,
29
+ builder,
30
+ idField,
31
+ id,
32
+ where,
33
+ allowDynamic: true,
34
+ });
35
+ if (call === null) return null;
36
+ const object = fixtureObjectOf(call);
37
+ if (object === null) return null;
38
+ // A spread later in the literal overrides an earlier key, so a literal built
39
+ // from one takes the property at the end instead — where it still applies.
40
+ if (holdsSpread({ object })) {
41
+ return call
42
+ .getRoot()
43
+ .root()
44
+ .commitEdits([appendPropertyEdit({ source, object, pair: property })]);
45
+ }
46
+ const first = object.namedChildren()[0];
47
+ if (first === undefined) return null;
48
+ const insertAt = first.range().start.index;
49
+ // The object spans lines: the existing whitespace before the first property
50
+ // already indents it, so the new property takes that indent on its own line.
51
+ const spansLines = source
52
+ .slice(object.range().start.index, insertAt)
53
+ .includes("\n");
54
+ if (!spansLines) {
55
+ return call
56
+ .getRoot()
57
+ .root()
58
+ .commitEdits([
59
+ { startPos: insertAt, endPos: insertAt, insertedText: `${property}, ` },
60
+ ]);
61
+ }
62
+ const indent = source.slice(lineStartOf(source, insertAt), insertAt);
63
+ return call
64
+ .getRoot()
65
+ .root()
66
+ .commitEdits([
67
+ {
68
+ startPos: insertAt,
69
+ endPos: insertAt,
70
+ insertedText: `${property},\n${indent}`,
71
+ },
72
+ ]);
73
+ };