@afokapu/atdd-bun 0.10.110 → 0.10.112
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/integrity.json +3 -3
- package/lib/journey-chain.mjs +275 -17
- package/package.json +1 -1
- package/src/hooks.ts +16 -7
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.112",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"README.md": "8aefceaa24bee498f39081b2aaf4c0d5c54bc1ed9849c20e1d98d1427df56eab",
|
|
@@ -1033,7 +1033,7 @@
|
|
|
1033
1033
|
"lib/frontend.mjs": "d6d9b29c3d7f2d162cda03bb34b20a3224a82887b6be3db7cab375510913c9a3",
|
|
1034
1034
|
"lib/imports.mjs": "3db4979269488fa26f6e7aefded6ece9c646be6587a10e249821a5a208109962",
|
|
1035
1035
|
"lib/interlocking.mjs": "d0803d080ac7d7477c51be70112df1e85213d339fd3c6b71f75f26145fa8accd",
|
|
1036
|
-
"lib/journey-chain.mjs": "
|
|
1036
|
+
"lib/journey-chain.mjs": "347418f944d92997c7c48913cd17bce99fc28649fa6db2f96bbd1ea8f18052c5",
|
|
1037
1037
|
"lib/scan.mjs": "b6973e411ecd14c548a055c871e01e948f002b772042136be8401f38d998e687",
|
|
1038
1038
|
"planner-nodes/ENFORCEMENT_SCOPE.yaml": "47aa9c78a4bb1754c6b435f5e056038baa04a24cd48df2c205db2bc08a3a3cac",
|
|
1039
1039
|
"planner-nodes/nodes/planner.acceptance.abstract-fields-required.convention.yaml": "bd565693f0ae861f0561ad2f7a91a8c55f9a209c3acd59c5750cfcffd96a85d8",
|
|
@@ -1269,7 +1269,7 @@
|
|
|
1269
1269
|
"src/cli.ts": "395638ab87565bb10c2beb35988ff1acf686924d13515795156a2e8893b3da88",
|
|
1270
1270
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1271
1271
|
"src/enforce.ts": "c4811abe63efe4568f3cf26e21a152d1537e120cb3ce471daca32367cf6e2cda",
|
|
1272
|
-
"src/hooks.ts": "
|
|
1272
|
+
"src/hooks.ts": "bdfc4560351a414741c8a5c94404570522033a92dbaac8079af09fa3847e82bc",
|
|
1273
1273
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1274
1274
|
"src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
|
|
1275
1275
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/lib/journey-chain.mjs
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// still proven to run on every live path.
|
|
36
36
|
import ts from "typescript";
|
|
37
37
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
38
|
-
import { dirname, join, resolve, sep } from "node:path";
|
|
38
|
+
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
39
39
|
|
|
40
40
|
const programs = new Map();
|
|
41
41
|
|
|
@@ -263,12 +263,38 @@ function optionsFor(file) {
|
|
|
263
263
|
return resolutionOptions.get(config);
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
// TypeScript sources a JavaScript-extension specifier may name (`./x.js` -> `./x.ts`), as TypeScript and Bun resolve them.
|
|
267
|
+
const TS_COUNTERPARTS = { ".js": [".ts", ".tsx"], ".mjs": [".mts"], ".cjs": [".cts"], ".jsx": [".tsx"] };
|
|
268
|
+
// Extensions an extensionless specifier may resolve to.
|
|
269
|
+
const MODULE_SUFFIXES = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".jsx"];
|
|
270
|
+
|
|
271
|
+
// Every file a relative `target` path can name: itself, each module suffix appended, `index` plus each suffix
|
|
272
|
+
// inside it as a directory, and the TypeScript counterpart of a JavaScript extension.
|
|
273
|
+
function relativeCandidates(target) {
|
|
274
|
+
const extension = extname(target);
|
|
275
|
+
const counterparts = (TS_COUNTERPARTS[extension] ?? []).map((replacement) => target.slice(0, -extension.length) + replacement);
|
|
276
|
+
return [target, ...MODULE_SUFFIXES.map((suffix) => target + suffix),
|
|
277
|
+
...MODULE_SUFFIXES.map((suffix) => join(target, `index${suffix}`)), ...counterparts];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Every local module `specifier` can name when imported from `current`, inside `croot` and outside node_modules.
|
|
281
|
+
// A relative specifier counts every candidate that exists, so a `.js` specifier covers both a real `.js` file and
|
|
282
|
+
// its TypeScript counterpart: neither can hide a module from the closure.
|
|
283
|
+
function resolveLocals(specifier, current, croot) {
|
|
284
|
+
const root = resolve(croot) + sep;
|
|
285
|
+
if (specifier.startsWith("."))
|
|
286
|
+
return relativeCandidates(resolve(dirname(current), specifier))
|
|
287
|
+
.filter((candidate) => candidate.startsWith(root) && existsSync(candidate) && statSync(candidate).isFile());
|
|
288
|
+
const resolved = ts.resolveModuleName(specifier, current, optionsFor(current), ts.sys).resolvedModule;
|
|
289
|
+
const target = resolved && !resolved.isExternalLibraryImport ? resolve(resolved.resolvedFileName) : null;
|
|
290
|
+
return target && target.startsWith(root) && !target.split(sep).includes("node_modules") && !target.endsWith(".d.ts") ? [target] : [];
|
|
291
|
+
}
|
|
292
|
+
|
|
266
293
|
// Every local module reachable from `file` through static, side-effect, re-export, dynamic `import()`
|
|
267
294
|
// and `require()` specifiers, including `file` itself. Relative specifiers resolve against the file;
|
|
268
295
|
// non-relative ones (tsconfig path aliases, package.json `#imports`) resolve through the TypeScript
|
|
269
296
|
// resolver and count when they land inside `croot` outside node_modules.
|
|
270
297
|
function localImportClosure(file, croot) {
|
|
271
|
-
const root = resolve(croot) + sep;
|
|
272
298
|
const seen = new Set([file]);
|
|
273
299
|
const queue = [file];
|
|
274
300
|
while (queue.length) {
|
|
@@ -280,20 +306,13 @@ function localImportClosure(file, croot) {
|
|
|
280
306
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) specifiers.push(node.moduleSpecifier.text);
|
|
281
307
|
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || isGlobal(ctx, node.expression, "require")) &&
|
|
282
308
|
node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) specifiers.push(node.arguments[0].text);
|
|
309
|
+
if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) &&
|
|
310
|
+
ts.isStringLiteralLike(node.moduleReference.expression)) specifiers.push(node.moduleReference.expression.text);
|
|
283
311
|
});
|
|
284
312
|
for (const specifier of specifiers) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const target = resolve(dirname(current), specifier);
|
|
288
|
-
found = [target, `${target}.ts`, `${target}.tsx`, `${target}.js`, `${target}.mjs`, join(target, "index.ts")]
|
|
289
|
-
.find((candidate) => existsSync(candidate) && statSync(candidate).isFile() && candidate.startsWith(root));
|
|
290
|
-
} else {
|
|
291
|
-
const resolved = ts.resolveModuleName(specifier, current, optionsFor(current), ts.sys).resolvedModule;
|
|
292
|
-
const target = resolved && !resolved.isExternalLibraryImport ? resolve(resolved.resolvedFileName) : null;
|
|
293
|
-
found = target && target.startsWith(root) && !target.split(sep).includes("node_modules") && !target.endsWith(".d.ts")
|
|
294
|
-
? target : undefined;
|
|
313
|
+
for (const found of resolveLocals(specifier, current, croot)) {
|
|
314
|
+
if (!seen.has(found)) { seen.add(found); queue.push(found); }
|
|
295
315
|
}
|
|
296
|
-
if (found && !seen.has(found)) { seen.add(found); queue.push(found); }
|
|
297
316
|
}
|
|
298
317
|
}
|
|
299
318
|
return seen;
|
|
@@ -1096,6 +1115,245 @@ function declaresAction(croot, path, action) {
|
|
|
1096
1115
|
}
|
|
1097
1116
|
}
|
|
1098
1117
|
|
|
1118
|
+
// ---- Object-form JourneyEntry dataflow -------------------------------------------------------------
|
|
1119
|
+
// A journey test may reach its declaration through a static JOURNEY_MAP instead of a path literal:
|
|
1120
|
+
// const entry = <map>[ACTION]; new JourneyRunner(entry.path, ...); execute({ ..., action: ACTION, ... }, ...)
|
|
1121
|
+
// where <map> is a `const JOURNEY_MAP` object literal in the test or exported by a local production module
|
|
1122
|
+
// (named or namespace import), ACTION and the key are const string literals, and nothing writes the map.
|
|
1123
|
+
|
|
1124
|
+
// The string a literal, or a chain of consts bound to one, evaluates to; else null.
|
|
1125
|
+
function staticString(ctx, node, seen = new Set()) {
|
|
1126
|
+
node = node && unwrap(node);
|
|
1127
|
+
if (!node) return null;
|
|
1128
|
+
if (ts.isStringLiteralLike(node)) return node.text;
|
|
1129
|
+
if (!ts.isIdentifier(node) || seen.has(node.text)) return null;
|
|
1130
|
+
return staticString(ctx, constInitializer(ctx, node), new Set([...seen, node.text]));
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// The property `name` of an object literal made only of plain `key: value` / shorthand members, else null.
|
|
1134
|
+
// A spread, computed key, method, accessor or duplicate could change the value, so each refuses the literal.
|
|
1135
|
+
function plainProperty(object, name) {
|
|
1136
|
+
const named = (member) => (ts.isPropertyAssignment(member) || ts.isShorthandPropertyAssignment(member)) && member.name &&
|
|
1137
|
+
(ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) ? member.name.text : null;
|
|
1138
|
+
if (!ts.isObjectLiteralExpression(object) || object.properties.some((member) => named(member) === null)) return null;
|
|
1139
|
+
const matches = object.properties.filter((member) => named(member) === name);
|
|
1140
|
+
return matches.length === 1 ? matches[0] : null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// The action of an execute request: a string literal, or an object literal whose sole `action` is static.
|
|
1144
|
+
function requestAction(ctx, request) {
|
|
1145
|
+
request = request && unwrap(request);
|
|
1146
|
+
if (!request) return null;
|
|
1147
|
+
if (ts.isStringLiteralLike(request)) return request.text;
|
|
1148
|
+
const member = plainProperty(request, "action");
|
|
1149
|
+
return member && staticString(ctx, ts.isShorthandPropertyAssignment(member) ? member.name : member.initializer);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const isTypeWrapper = (node) => ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node) ||
|
|
1153
|
+
ts.isSatisfiesExpression(node) || ts.isTypeAssertionExpression(node);
|
|
1154
|
+
|
|
1155
|
+
// The outermost wrapper/member-access chain around `node` and how many member accesses it holds.
|
|
1156
|
+
function climb(node) {
|
|
1157
|
+
let length = 0;
|
|
1158
|
+
for (let parent = node.parent; parent; parent = node.parent) {
|
|
1159
|
+
if (isTypeWrapper(parent) && parent.expression === node) node = parent;
|
|
1160
|
+
else if ((ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && parent.expression === node) {
|
|
1161
|
+
node = parent;
|
|
1162
|
+
length++;
|
|
1163
|
+
} else break;
|
|
1164
|
+
}
|
|
1165
|
+
return { top: node, length };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// `node` sits in a destructuring-assignment pattern (`({ p: x.y } = v)`, `[x.y] = v`, nested, spread or defaulted)
|
|
1169
|
+
// or is the head of a `for (x.y of|in v)`: the pattern or loop writes it.
|
|
1170
|
+
function inAssignmentPattern(node) {
|
|
1171
|
+
for (let child = node, parent = child.parent; parent; child = parent, parent = child.parent) {
|
|
1172
|
+
if (ts.isForOfStatement(parent) || ts.isForInStatement(parent)) return parent.initializer === child;
|
|
1173
|
+
if (ts.isBinaryExpression(parent)) return parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.left === child;
|
|
1174
|
+
const transparent = isTypeWrapper(parent) || ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent) ||
|
|
1175
|
+
ts.isArrayLiteralExpression(parent) || ts.isObjectLiteralExpression(parent) ||
|
|
1176
|
+
((ts.isPropertyAssignment(parent) || ts.isShorthandPropertyAssignment(parent)) && parent.name !== child);
|
|
1177
|
+
if (!transparent) return false;
|
|
1178
|
+
}
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function writesTo(node) {
|
|
1183
|
+
const parent = node.parent;
|
|
1184
|
+
return (ts.isBinaryExpression(parent) && parent.left === node &&
|
|
1185
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
|
|
1186
|
+
((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
|
|
1187
|
+
(parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken)) ||
|
|
1188
|
+
ts.isDeleteExpression(parent) || inAssignmentPattern(node);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// An identifier occurrence that declares or names the symbol rather than using it.
|
|
1192
|
+
function declaresOrNames(node) {
|
|
1193
|
+
const parent = node.parent;
|
|
1194
|
+
return (ts.isVariableDeclaration(parent) && parent.name === node) || ts.isTypeQueryNode(parent) || ts.isExportSpecifier(parent) ||
|
|
1195
|
+
ts.isImportSpecifier(parent) || ts.isNamespaceImport(parent) || (ts.isImportEqualsDeclaration(parent) && parent.name === node);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// A use that only tests the value (`!x`, `if (x)`, `x ? a : b`, `x && y`, `x === y`), never keeps or changes it.
|
|
1199
|
+
function testsOnly(node) {
|
|
1200
|
+
const parent = node.parent;
|
|
1201
|
+
return (ts.isPrefixUnaryExpression(parent) && parent.operator === ts.SyntaxKind.ExclamationToken) ||
|
|
1202
|
+
(ts.isIfStatement(parent) && parent.expression === node) || (ts.isConditionalExpression(parent) && parent.condition === node) ||
|
|
1203
|
+
(ts.isBinaryExpression(parent) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken,
|
|
1204
|
+
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken,
|
|
1205
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(parent.operatorToken.kind));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Nothing writes the map, an entry of it, or a const alias of either. `depth` is how many members deep the
|
|
1209
|
+
// bound value sits (map 0, entry 1; a namespace import -1, with `member` selecting its JOURNEY_MAP member).
|
|
1210
|
+
// A map or entry that is passed on, returned or stored anywhere else could be written out of sight: refused.
|
|
1211
|
+
// A namespace binding (`member` set) may appear only as `ns.<member>` or `ns.<other export>`; an alias,
|
|
1212
|
+
// destructure, element access, argument or export of the namespace itself is refused for the same reason.
|
|
1213
|
+
function unwritten(ctx, symbol, depth, member = null, seen = new Set()) {
|
|
1214
|
+
if (seen.has(symbol)) return true;
|
|
1215
|
+
seen.add(symbol);
|
|
1216
|
+
let ok = true;
|
|
1217
|
+
walk(ctx.source, (node) => {
|
|
1218
|
+
if (!ok) return false;
|
|
1219
|
+
if (!ts.isIdentifier(node) || symbolOf(ctx, node) !== symbol || declaresOrNames(node)) return;
|
|
1220
|
+
if (member) {
|
|
1221
|
+
// A namespace may be used only as `ns.<member>` (checked below) or `ns.<other export>`; any other use
|
|
1222
|
+
// (alias, destructure, element access, argument, export) could reach the map out of sight.
|
|
1223
|
+
const access = ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node ? node.parent : null;
|
|
1224
|
+
if (!access) { ok = false; return; }
|
|
1225
|
+
if (access.name.text !== member) return;
|
|
1226
|
+
}
|
|
1227
|
+
const { top, length } = climb(node);
|
|
1228
|
+
const total = depth + length;
|
|
1229
|
+
const alias = ts.isVariableDeclaration(top.parent) && top.parent.initializer === top && ts.isIdentifier(top.parent.name) &&
|
|
1230
|
+
ts.isVariableDeclarationList(top.parent.parent) && Boolean(top.parent.parent.flags & ts.NodeFlags.Const);
|
|
1231
|
+
if (writesTo(top)) ok = false;
|
|
1232
|
+
else if (total >= 2 || testsOnly(top)) return;
|
|
1233
|
+
else if (alias) ok = unwritten(ctx, symbolOf(ctx, top.parent.name), total, null, seen);
|
|
1234
|
+
else ok = false;
|
|
1235
|
+
});
|
|
1236
|
+
return ok;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// The top-level `const JOURNEY_MAP` object literal of `moduleCtx` (exported when it is another module's).
|
|
1240
|
+
function mapLiteral(moduleCtx, exported) {
|
|
1241
|
+
for (const statement of moduleCtx.source.statements) {
|
|
1242
|
+
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
|
|
1243
|
+
if (exported && !(ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export)) continue;
|
|
1244
|
+
const declaration = statement.declarationList.declarations.find((item) => ts.isIdentifier(item.name) && item.name.text === "JOURNEY_MAP");
|
|
1245
|
+
const literal = declaration?.initializer && unwrap(declaration.initializer);
|
|
1246
|
+
if (literal && ts.isObjectLiteralExpression(literal)) return { ctx: moduleCtx, declaration, literal };
|
|
1247
|
+
}
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// The local production module a named JOURNEY_MAP import or a namespace import binds to, else null.
|
|
1252
|
+
function mapModule(declaration, named, ctx, croot) {
|
|
1253
|
+
const clause = named
|
|
1254
|
+
? ts.isImportSpecifier(declaration) && !declaration.isTypeOnly && (declaration.propertyName ?? declaration.name).text === "JOURNEY_MAP"
|
|
1255
|
+
? declaration.parent.parent
|
|
1256
|
+
: null
|
|
1257
|
+
: ts.isNamespaceImport(declaration) ? declaration.parent : null;
|
|
1258
|
+
if (!clause || clause.isTypeOnly || !ts.isStringLiteral(clause.parent.moduleSpecifier)) return null;
|
|
1259
|
+
return productionModule(clause.parent.moduleSpecifier.text, ctx.file, croot);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// The binding `symbol` is re-exported by an `export { ... }` of the module.
|
|
1263
|
+
function exportedBinding(ctx, symbol) {
|
|
1264
|
+
let found = false;
|
|
1265
|
+
walk(ctx.source, (child) => {
|
|
1266
|
+
found ||= ts.isExportSpecifier(child) && ctx.checker.getExportSpecifierLocalTargetSymbol(child) === symbol;
|
|
1267
|
+
});
|
|
1268
|
+
return found;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// The import binds JOURNEY_MAP (by name or through a namespace) and nothing writes, hands on or re-exports it.
|
|
1272
|
+
function importReadOnly(ctx, node) {
|
|
1273
|
+
const bindings = node.importClause?.namedBindings;
|
|
1274
|
+
if (!bindings || node.importClause.isTypeOnly) return true;
|
|
1275
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
1276
|
+
const symbol = symbolOf(ctx, bindings.name);
|
|
1277
|
+
return !exportedBinding(ctx, symbol) && unwritten(ctx, symbol, -1, "JOURNEY_MAP");
|
|
1278
|
+
}
|
|
1279
|
+
return bindings.elements.every((element) => {
|
|
1280
|
+
if (element.isTypeOnly || (element.propertyName ?? element.name).text !== "JOURNEY_MAP") return true;
|
|
1281
|
+
const symbol = symbolOf(ctx, element.name);
|
|
1282
|
+
return !exportedBinding(ctx, symbol) && unwritten(ctx, symbol, 0);
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// `import ns = require(<map module>)` binds the module namespace: it must stay unexported and be used like a namespace import.
|
|
1287
|
+
function importEqualsReadOnly(ctx, node) {
|
|
1288
|
+
if (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) return false;
|
|
1289
|
+
const symbol = symbolOf(ctx, node.name);
|
|
1290
|
+
return !exportedBinding(ctx, symbol) && unwritten(ctx, symbol, -1, "JOURNEY_MAP");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Whether `node` of a module in the closure leaves the map module's JOURNEY_MAP read-only. A re-export, dynamic
|
|
1294
|
+
// `import()` or `require()` of the map module hands the map on out of sight and is refused.
|
|
1295
|
+
function readsMapOnly(ctx, node, reaches) {
|
|
1296
|
+
if (ts.isImportDeclaration(node)) return !reaches(node.moduleSpecifier) || importReadOnly(ctx, node);
|
|
1297
|
+
if (ts.isExportDeclaration(node)) return !reaches(node.moduleSpecifier) || node.isTypeOnly;
|
|
1298
|
+
if (ts.isImportEqualsDeclaration(node)) {
|
|
1299
|
+
const reference = node.moduleReference;
|
|
1300
|
+
return node.isTypeOnly || !ts.isExternalModuleReference(reference) || !reaches(reference.expression) || importEqualsReadOnly(ctx, node);
|
|
1301
|
+
}
|
|
1302
|
+
const call = ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || isGlobal(ctx, node.expression, "require"));
|
|
1303
|
+
return !call || !reaches(node.arguments[0]);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Every module of the local import closure of `testFile` that reaches the map module `mapFile` only reads
|
|
1307
|
+
// JOURNEY_MAP: a side-effect module could otherwise rewrite an entry before the test runs.
|
|
1308
|
+
function mapReadOnlyInClosure(testFile, croot, mapFile) {
|
|
1309
|
+
return [...localImportClosure(testFile, croot)].filter((file) => file !== mapFile).every((file) => {
|
|
1310
|
+
const ctx = analyze(file);
|
|
1311
|
+
const reaches = (specifier) => Boolean(specifier) && ts.isStringLiteralLike(specifier) && resolveLocals(specifier.text, file, croot).includes(mapFile);
|
|
1312
|
+
let ok = true;
|
|
1313
|
+
walk(ctx.source, (node) => {
|
|
1314
|
+
ok &&= readsMapOnly(ctx, node, reaches);
|
|
1315
|
+
return ok;
|
|
1316
|
+
});
|
|
1317
|
+
return ok;
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// The JOURNEY_MAP `expression` names ({ ctx, declaration, literal }), provided neither the module that
|
|
1322
|
+
// declares it nor the binding that reaches it writes it; else null.
|
|
1323
|
+
function resolvedMap(ctx, expression, croot) {
|
|
1324
|
+
expression = unwrap(expression);
|
|
1325
|
+
const namespaced = ts.isPropertyAccessExpression(expression) && expression.name.text === "JOURNEY_MAP";
|
|
1326
|
+
const binding = namespaced ? expression.expression : expression;
|
|
1327
|
+
const named = !namespaced;
|
|
1328
|
+
if (!ts.isIdentifier(binding) || (named && binding.text !== "JOURNEY_MAP")) return null;
|
|
1329
|
+
const declaration = declarationOf(ctx, binding);
|
|
1330
|
+
if (!declaration) return null;
|
|
1331
|
+
if (named && ts.isVariableDeclaration(declaration)) {
|
|
1332
|
+
const local = mapLiteral(ctx, false);
|
|
1333
|
+
return local && local.declaration === declaration && unwritten(ctx, symbolOf(ctx, binding), 0) ? local : null;
|
|
1334
|
+
}
|
|
1335
|
+
const module = mapModule(declaration, named, ctx, croot);
|
|
1336
|
+
const map = module && mapLiteral(analyze(module), true);
|
|
1337
|
+
if (!map || !unwritten(map.ctx, symbolOf(map.ctx, map.declaration.name), 0)) return null;
|
|
1338
|
+
return mapReadOnlyInClosure(ctx.file, croot, module) ? map : null;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// The path of `<entry>.path` where `entry` is a const bound to `<JOURNEY_MAP>[<key>]` and key is `action`;
|
|
1342
|
+
// a string literal is its own path. Anything else (dynamic, other field, other action) is null.
|
|
1343
|
+
function journeyPath(ctx, node, action, croot) {
|
|
1344
|
+
node = node && unwrap(node);
|
|
1345
|
+
if (!node) return null;
|
|
1346
|
+
if (ts.isStringLiteralLike(node)) return node.text;
|
|
1347
|
+
if (!ts.isPropertyAccessExpression(node) || node.name.text !== "path" || !ts.isIdentifier(unwrap(node.expression))) return null;
|
|
1348
|
+
const lookup = constInitializer(ctx, unwrap(node.expression));
|
|
1349
|
+
const access = lookup && unwrap(lookup);
|
|
1350
|
+
if (!access || !ts.isElementAccessExpression(access) || staticString(ctx, access.argumentExpression) !== action) return null;
|
|
1351
|
+
const map = resolvedMap(ctx, access.expression, croot);
|
|
1352
|
+
const entry = map && plainProperty(map.literal, action);
|
|
1353
|
+
const path = entry && ts.isPropertyAssignment(entry) && plainProperty(unwrap(entry.initializer), "path");
|
|
1354
|
+
return path && ts.isPropertyAssignment(path) ? staticString(map.ctx, path.initializer) : null;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1099
1357
|
// A test module asserts a production JourneyRunner execution result, proven like provenJourneyAction:
|
|
1100
1358
|
// - a live `const` bound to `await <receiver>.execute("<action>", ...)`, the receiver being
|
|
1101
1359
|
// `new JourneyRunner("<journey declaration>")` or a const bound to one;
|
|
@@ -1114,11 +1372,11 @@ export function assertedJourneyExecution(testText, testFile, croot) {
|
|
|
1114
1372
|
if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer ||
|
|
1115
1373
|
!(ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const)) return;
|
|
1116
1374
|
const awaited = ts.isAwaitExpression(node.initializer) ? unwrap(node.initializer) : null;
|
|
1117
|
-
const action = awaited && ts.isCallExpression(awaited) && awaited.arguments[0];
|
|
1118
|
-
if (!action ||
|
|
1375
|
+
const action = awaited && ts.isCallExpression(awaited) && requestAction(ctx, awaited.arguments[0]);
|
|
1376
|
+
if (!action || dead(node, ctx.source) || insideCatch(node, ctx.source)) return;
|
|
1119
1377
|
const fromDeclaration = (construction) => {
|
|
1120
|
-
const path = construction.arguments?.[0];
|
|
1121
|
-
return
|
|
1378
|
+
const path = journeyPath(ctx, construction.arguments?.[0], action, croot);
|
|
1379
|
+
return path !== null && declaresAction(croot, path, action);
|
|
1122
1380
|
};
|
|
1123
1381
|
const modules = new Set();
|
|
1124
1382
|
if (executesImported(ctx, "JourneyRunner", croot, modules, fromDeclaration)(awaited) &&
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -411,14 +411,14 @@ async function branchCommit(root: string, branch: string) {
|
|
|
411
411
|
|
|
412
412
|
// A delivery commit must have one unambiguous non-protected exported bookmark.
|
|
413
413
|
// Ref ordering is never used as ownership authority.
|
|
414
|
-
async function branchForCommit(root: string, commit: string, protectedBranches: string[]): Promise<{ error: string } | { branch: string }> {
|
|
414
|
+
async function branchForCommit(root: string, commit: string, protectedBranches: string[]): Promise<{ error: string; count?: number } | { branch: string }> {
|
|
415
415
|
const refs = await git(root, ["for-each-ref", "--format=%(refname:short)", "--points-at", commit, "refs/heads/"]);
|
|
416
416
|
if (refs.code) return { error: refs.err || refs.out };
|
|
417
417
|
const candidates = refs.out.split("\n").map(line => line.trim()).filter(Boolean)
|
|
418
418
|
.filter(name => !protectedBranches.includes(name));
|
|
419
419
|
return candidates.length === 1
|
|
420
420
|
? { branch: candidates[0]! }
|
|
421
|
-
: { error: `current Jujutsu revision must have exactly one non-protected exported bookmark; found ${candidates.length}
|
|
421
|
+
: { error: `current Jujutsu revision must have exactly one non-protected exported bookmark; found ${candidates.length}`, count: candidates.length };
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
// A JJ-colocated primary keeps Git HEAD detached at its working-copy parent, so JJ state decides:
|
|
@@ -482,13 +482,16 @@ async function createWorkspace(state: Layout, target: { leaf: string; target: st
|
|
|
482
482
|
return bad(bookmark.err || bookmark.out || `bookmark ${branch} was not exported to the colocated Git repository`);
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
|
|
485
|
+
// A branch of null means the delivery bookmark is already gone and the revision is merged.
|
|
486
|
+
type FinishCandidate = { branch: string | null; tip: string };
|
|
487
|
+
async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error: string } | FinishCandidate> {
|
|
486
488
|
if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return { error: `finish must run from a linked workspace beneath ${state.worktreeRoot}` };
|
|
487
489
|
const conflict = await noConflicts(state.current);
|
|
488
490
|
if (conflict.error !== undefined) return conflict;
|
|
489
491
|
const current = await currentCommit(state.current);
|
|
490
492
|
if (!current) return { error: "could not resolve the current Jujutsu revision" };
|
|
491
493
|
let delivery = current, found = await branchForCommit(state.current, current, cfg.protected_branches);
|
|
494
|
+
if ("error" in found && found.count === 0 && await mergedIntoPrimary(state, current, cfg)) return { branch: null, tip: current };
|
|
492
495
|
if ("error" in found) {
|
|
493
496
|
const changed = await jj(state.current, ["diff", "-r", "@", "--name-only"]);
|
|
494
497
|
const parent = await jj(state.current, ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
|
|
@@ -504,6 +507,11 @@ async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error:
|
|
|
504
507
|
return undelivered ? { error: undelivered } : { branch: found.branch, tip };
|
|
505
508
|
}
|
|
506
509
|
|
|
510
|
+
// Zero-bookmark retirement is safe only for a revision already equal to or an ancestor of protected main.
|
|
511
|
+
async function mergedIntoPrimary(state: Layout, commit: string, cfg: HookPolicy) {
|
|
512
|
+
return !(await git(state.primary, ["merge-base", "--is-ancestor", commit, cfg.worktrees.primary_branch])).code;
|
|
513
|
+
}
|
|
514
|
+
|
|
507
515
|
async function jjRevision(root: string, commit: string): Promise<{ error: string } | { empty: boolean; conflict: boolean; parents: string[] }> {
|
|
508
516
|
const info = await jj(root, ["log", "-r", commit, "--no-graph", "-T", 'if(empty, "empty", "content") ++ " " ++ if(conflict, "conflict", "clean") ++ " " ++ parents.map(|p| p.commit_id()).join(" ")']);
|
|
509
517
|
if (info.code || !info.out) return { error: `could not inspect Jujutsu revision ${commit}: ${info.err || info.out}` };
|
|
@@ -520,7 +528,7 @@ async function deliveryError(state: Layout, branch: string, tip: string, cfg: Ho
|
|
|
520
528
|
const info = await jjRevision(state.primary, commit);
|
|
521
529
|
if ("error" in info) return info.error;
|
|
522
530
|
if (info.conflict) return `bookmark ${branch} has unresolved conflicts`;
|
|
523
|
-
if (
|
|
531
|
+
if (await mergedIntoPrimary(state, commit, cfg)) return null;
|
|
524
532
|
if (!info.empty || info.parents.length !== 1) return `branch ${branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`;
|
|
525
533
|
commit = info.parents[0]!;
|
|
526
534
|
}
|
|
@@ -573,9 +581,10 @@ async function finishJjWorktree(root: string, deleteBranch: boolean, cfg: HookPo
|
|
|
573
581
|
if ("error" in workspace) return bad(workspace.error);
|
|
574
582
|
const removed = await jj(state.primary, ["workspace", "remove", workspace.name]);
|
|
575
583
|
if (removed.code) return bad(removed.err || removed.out);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
584
|
+
const { branch } = candidate;
|
|
585
|
+
if (!deleteBranch || branch === null) return { ok: true, message: `removed merged Jujutsu workspace ${state.current}` };
|
|
586
|
+
const deletionError = await deleteBookmark(state, { branch, tip: candidate.tip }, cfg);
|
|
587
|
+
return deletionError ? bad(deletionError) : { ok: true, message: `removed merged Jujutsu workspace ${state.current} and bookmark ${branch}` };
|
|
579
588
|
}
|
|
580
589
|
|
|
581
590
|
|