@abelxiaoxing/cadence 1.0.2 → 1.0.4

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.
@@ -15,6 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import path from "node:path";
17
17
  import {
18
+ type CandidateFailure,
18
19
  diffWritePaths,
19
20
  isValidRelativePath,
20
21
  type VerificationContract,
@@ -42,6 +43,7 @@ export interface CandidatePreflightInput {
42
43
  root: string;
43
44
  diff: Buffer;
44
45
  writeSet: string[];
46
+ approvedDependencies: string[];
45
47
  snapshot: Bound;
46
48
  baseline: BaselineEntry[];
47
49
  verification: VerificationContract;
@@ -51,6 +53,16 @@ export interface CandidatePreflightInput {
51
53
  signal?: AbortSignal;
52
54
  }
53
55
 
56
+ type PreflightCandidateFailure = Exclude<
57
+ CandidateFailure,
58
+ { kind: "result-limit" }
59
+ >;
60
+ type PreflightFailureKind = PreflightCandidateFailure["kind"];
61
+ type PreflightFailureFor<Kind extends PreflightFailureKind> = Extract<
62
+ PreflightCandidateFailure,
63
+ { kind: Kind }
64
+ >;
65
+
54
66
  export type CandidatePreflightResult =
55
67
  | {
56
68
  ok: true;
@@ -62,17 +74,15 @@ export type CandidatePreflightResult =
62
74
  identityMatch: boolean;
63
75
  checkoutRemoved: true;
64
76
  }
65
- | {
77
+ | (PreflightCandidateFailure & {
66
78
  ok: false;
67
- class: "artifact" | "design" | "environment" | "stale" | "cancelled";
68
- code: string;
69
79
  commandId?: string;
70
80
  exitCode?: number;
71
81
  testCount?: number;
72
82
  identityMatch?: boolean;
73
83
  checkoutRemoved: boolean;
74
84
  excerpt?: string;
75
- };
85
+ });
76
86
 
77
87
  export interface CandidatePreflightDependencies {
78
88
  mkdtemp: typeof mkdtempSync;
@@ -113,20 +123,15 @@ interface FailureDetails {
113
123
 
114
124
  class PreflightFailure extends Error {
115
125
  constructor(
116
- readonly failureClass:
117
- | "artifact"
118
- | "design"
119
- | "environment"
120
- | "stale"
121
- | "cancelled",
122
- readonly code: string,
126
+ readonly failure: PreflightCandidateFailure,
123
127
  readonly details: FailureDetails = {},
124
128
  ) {
125
- super(code);
129
+ super(failure.code);
126
130
  }
127
131
  }
128
132
 
129
133
  const SHA256 = /^[0-9a-f]{64}$/;
134
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/iu;
130
135
  const LOCKFILES = [
131
136
  "bun.lock",
132
137
  "bun.lockb",
@@ -210,27 +215,33 @@ function spawnCommand(
210
215
  });
211
216
  }
212
217
 
213
- function reject(
214
- failureClass: PreflightFailure["failureClass"],
215
- code: string,
218
+ function typedFailure<Kind extends PreflightFailureKind>(
219
+ kind: Kind,
220
+ code: PreflightFailureFor<Kind>["code"],
221
+ ): PreflightFailureFor<Kind> {
222
+ return { kind, code } as PreflightFailureFor<Kind>;
223
+ }
224
+
225
+ function reject<Kind extends PreflightFailureKind>(
226
+ kind: Kind,
227
+ code: PreflightFailureFor<Kind>["code"],
216
228
  details: FailureDetails = {},
217
229
  ): never {
218
- throw new PreflightFailure(failureClass, code, details);
230
+ throw new PreflightFailure(typedFailure(kind, code), details);
219
231
  }
220
232
 
221
- function failed(
222
- failureClass: PreflightFailure["failureClass"],
223
- code: string,
233
+ function failed<Kind extends PreflightFailureKind>(
234
+ kind: Kind,
235
+ code: PreflightFailureFor<Kind>["code"],
224
236
  details: FailureDetails = {},
225
237
  checkoutRemoved = true,
226
238
  ): CandidatePreflightResult {
227
239
  return {
228
240
  ok: false,
229
- class: failureClass,
230
- code,
241
+ ...typedFailure(kind, code),
231
242
  ...details,
232
243
  checkoutRemoved,
233
- };
244
+ } as CandidatePreflightResult;
234
245
  }
235
246
 
236
247
  function removeCheckout(
@@ -329,22 +340,13 @@ async function run(
329
340
  ...(input === undefined ? {} : { input }),
330
341
  ...(signal === undefined ? {} : { signal }),
331
342
  };
332
- try {
333
- const result = await dependencies.spawn(command, args, options);
334
- return {
335
- status: result.status,
336
- stdout: result.stdout ?? "",
337
- stderr: result.stderr ?? "",
338
- ...(result.error === undefined ? {} : { error: result.error }),
339
- };
340
- } catch (error) {
341
- return {
342
- status: null,
343
- stdout: "",
344
- stderr: "",
345
- error: error as Error,
346
- };
347
- }
343
+ const result = await dependencies.spawn(command, args, options);
344
+ return {
345
+ status: result.status,
346
+ stdout: result.stdout ?? "",
347
+ stderr: result.stderr ?? "",
348
+ ...(result.error === undefined ? {} : { error: result.error }),
349
+ };
348
350
  }
349
351
 
350
352
  function commandExcerpt(
@@ -381,6 +383,16 @@ function validateInput(input: CandidatePreflightInput): {
381
383
  if (!validSnapshot(input.snapshot)) reject("artifact", "invalid-snapshot");
382
384
  const verification = validateVerificationContract(input.verification);
383
385
  if (!verification.ok) reject("artifact", "invalid-verification-contract");
386
+ if (
387
+ !Array.isArray(input.approvedDependencies) ||
388
+ input.approvedDependencies.some(
389
+ (dependency) => typeof dependency !== "string" || dependency.length === 0,
390
+ ) ||
391
+ new Set(input.approvedDependencies).size !==
392
+ input.approvedDependencies.length
393
+ ) {
394
+ reject("artifact", "invalid-approved-dependencies");
395
+ }
384
396
 
385
397
  let targets: string[];
386
398
  try {
@@ -477,8 +489,9 @@ function validateInput(input: CandidatePreflightInput): {
477
489
  }
478
490
  if (targets.some((target) => !entries.has(target)))
479
491
  reject("artifact", "target-baseline-missing");
480
- for (const testPath of input.verification.argv.slice(3)) {
481
- if (!entries.has(testPath))
492
+ for (let index = 3; index < input.verification.argv.length; index++) {
493
+ const testPath = input.verification.argv[index];
494
+ if (testPath === undefined || !entries.has(testPath))
482
495
  reject("artifact", "verification-baseline-missing");
483
496
  }
484
497
  return { targets, lockPath, dependencyPath };
@@ -786,6 +799,243 @@ function structuredTestReport(reportPath: string): StructuredTestReport {
786
799
  };
787
800
  }
788
801
 
802
+ interface DependencyContractEntry {
803
+ value: string;
804
+ names: string[];
805
+ }
806
+
807
+ function canonicalJson(value: unknown): string {
808
+ if (Array.isArray(value)) {
809
+ return `[${value.map(canonicalJson).join(",")}]`;
810
+ }
811
+ if (value && typeof value === "object") {
812
+ return `{${Object.entries(value as Record<string, unknown>)
813
+ .sort(([left], [right]) => left.localeCompare(right))
814
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
815
+ .join(",")}}`;
816
+ }
817
+ return JSON.stringify(value);
818
+ }
819
+
820
+ function dependencyName(selector: string): string | null {
821
+ const scoped = /(@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*)/iu.exec(
822
+ selector,
823
+ )?.[1];
824
+ if (scoped) return scoped;
825
+ const segment = selector.split("/").at(-1) ?? "";
826
+ const name = segment.replace(/@.*$/u, "");
827
+ return /^[a-z0-9][a-z0-9._-]*$/iu.test(name) ? name : null;
828
+ }
829
+
830
+ function addResolutionEntries(
831
+ result: Map<string, DependencyContractEntry>,
832
+ section: string,
833
+ value: unknown,
834
+ path: string[] = [],
835
+ names: string[] = [],
836
+ ): void {
837
+ if (value && typeof value === "object" && !Array.isArray(value)) {
838
+ for (const [key, entry] of Object.entries(
839
+ value as Record<string, unknown>,
840
+ )) {
841
+ const name = dependencyName(key);
842
+ addResolutionEntries(
843
+ result,
844
+ section,
845
+ entry,
846
+ [...path, key],
847
+ name && key !== "." ? [...new Set([...names, name])] : names,
848
+ );
849
+ }
850
+ return;
851
+ }
852
+ result.set(`${section}:${path.join(":")}`, {
853
+ value: canonicalJson(value),
854
+ names,
855
+ });
856
+ }
857
+
858
+ function addPackageListPolicy(
859
+ result: Map<string, DependencyContractEntry>,
860
+ section: string,
861
+ value: unknown,
862
+ ): void {
863
+ if (value === undefined) return;
864
+ if (
865
+ !Array.isArray(value) ||
866
+ value.some(
867
+ (name) => typeof name !== "string" || !PACKAGE_NAME.test(name),
868
+ ) ||
869
+ new Set(value).size !== value.length
870
+ ) {
871
+ reject("artifact", "package-manifest-invalid");
872
+ }
873
+ result.set(section, {
874
+ value: canonicalJson([...value].sort()),
875
+ names: [],
876
+ });
877
+ }
878
+
879
+ function addPatchedDependencies(
880
+ result: Map<string, DependencyContractEntry>,
881
+ section: string,
882
+ value: unknown,
883
+ ): void {
884
+ if (value === undefined) return;
885
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
886
+ reject("artifact", "package-manifest-invalid");
887
+ }
888
+ for (const [selector, patchPath] of Object.entries(value)) {
889
+ if (
890
+ !dependencyName(selector) ||
891
+ typeof patchPath !== "string" ||
892
+ !isValidRelativePath(patchPath)
893
+ ) {
894
+ reject("artifact", "package-manifest-invalid");
895
+ }
896
+ }
897
+ result.set(section, { value: canonicalJson(value), names: [] });
898
+ }
899
+
900
+ function dependencyContract(
901
+ manifestPath: string,
902
+ ): Map<string, DependencyContractEntry> {
903
+ let manifest: unknown;
904
+ try {
905
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
906
+ } catch {
907
+ reject("artifact", "package-manifest-invalid");
908
+ }
909
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
910
+ reject("artifact", "package-manifest-invalid");
911
+ }
912
+ const record = manifest as Record<string, unknown>;
913
+ const result = new Map<string, DependencyContractEntry>();
914
+ for (const section of [
915
+ "dependencies",
916
+ "devDependencies",
917
+ "peerDependencies",
918
+ "optionalDependencies",
919
+ ]) {
920
+ const dependencies = record[section];
921
+ if (dependencies === undefined) continue;
922
+ if (
923
+ !dependencies ||
924
+ typeof dependencies !== "object" ||
925
+ Array.isArray(dependencies)
926
+ ) {
927
+ reject("artifact", "package-manifest-invalid");
928
+ }
929
+ for (const [name, version] of Object.entries(dependencies)) {
930
+ if (typeof version !== "string")
931
+ reject("artifact", "package-manifest-invalid");
932
+ result.set(`${section}:${name}`, { value: version, names: [name] });
933
+ }
934
+ }
935
+ for (const section of [
936
+ "overrides",
937
+ "resolutions",
938
+ "dependenciesMeta",
939
+ "peerDependenciesMeta",
940
+ ]) {
941
+ if (record[section] !== undefined) {
942
+ addResolutionEntries(result, section, record[section]);
943
+ }
944
+ }
945
+ for (const section of ["workspaces", "catalog", "catalogs"] as const) {
946
+ if (record[section] !== undefined) {
947
+ result.set(section, {
948
+ value: canonicalJson(record[section]),
949
+ names: [],
950
+ });
951
+ }
952
+ }
953
+ addPackageListPolicy(
954
+ result,
955
+ "trustedDependencies",
956
+ record.trustedDependencies,
957
+ );
958
+ addPackageListPolicy(
959
+ result,
960
+ "blockedDependencies",
961
+ record.blockedDependencies,
962
+ );
963
+ addPackageListPolicy(
964
+ result,
965
+ "bundledDependencies",
966
+ record.bundledDependencies,
967
+ );
968
+ addPackageListPolicy(result, "bundleDependencies", record.bundleDependencies);
969
+ addPatchedDependencies(
970
+ result,
971
+ "patchedDependencies",
972
+ record.patchedDependencies,
973
+ );
974
+ const pnpm = record.pnpm;
975
+ if (pnpm !== undefined) {
976
+ if (!pnpm || typeof pnpm !== "object" || Array.isArray(pnpm)) {
977
+ reject("artifact", "package-manifest-invalid");
978
+ }
979
+ const pnpmRecord = pnpm as Record<string, unknown>;
980
+ const resolutionSections = [
981
+ "overrides",
982
+ "packageExtensions",
983
+ "peerDependencyRules",
984
+ "allowedDeprecatedVersions",
985
+ ] as const;
986
+ for (const section of resolutionSections) {
987
+ const value = pnpmRecord[section];
988
+ if (value !== undefined) {
989
+ addResolutionEntries(result, `pnpm.${section}`, value);
990
+ }
991
+ }
992
+ addPatchedDependencies(
993
+ result,
994
+ "pnpm.patchedDependencies",
995
+ pnpmRecord.patchedDependencies,
996
+ );
997
+ const tracked = new Set<string>([
998
+ ...resolutionSections,
999
+ "patchedDependencies",
1000
+ ]);
1001
+ const policy = Object.fromEntries(
1002
+ Object.entries(pnpmRecord).filter(([key]) => !tracked.has(key)),
1003
+ );
1004
+ if (Object.keys(policy).length > 0) {
1005
+ result.set("pnpm.policy", { value: canonicalJson(policy), names: [] });
1006
+ }
1007
+ }
1008
+ return result;
1009
+ }
1010
+
1011
+ function rejectUnapprovedDependencies(
1012
+ before: Map<string, DependencyContractEntry>,
1013
+ after: Map<string, DependencyContractEntry>,
1014
+ approved: string[],
1015
+ lockfileChanged: boolean,
1016
+ ): boolean {
1017
+ const approvedNames = new Set(approved);
1018
+ const keys = new Set([...before.keys(), ...after.keys()]);
1019
+ let manifestChanged = false;
1020
+ for (const key of keys) {
1021
+ const previous = before.get(key);
1022
+ const next = after.get(key);
1023
+ if (previous?.value === next?.value) continue;
1024
+ manifestChanged = true;
1025
+ const names = new Set([...(previous?.names ?? []), ...(next?.names ?? [])]);
1026
+ if (
1027
+ names.size === 0 ||
1028
+ [...names].some((name) => !approvedNames.has(name))
1029
+ ) {
1030
+ reject("approval-boundary", "unapproved-dependency-change");
1031
+ }
1032
+ }
1033
+ if (lockfileChanged) {
1034
+ reject("approval-boundary", "unapproved-dependency-change");
1035
+ }
1036
+ return manifestChanged;
1037
+ }
1038
+
789
1039
  function normalizeTarget(
790
1040
  input: CandidatePreflightInput,
791
1041
  result: CommandResult,
@@ -829,7 +1079,7 @@ function normalizeTarget(
829
1079
  enoughTests &&
830
1080
  loadable
831
1081
  ) {
832
- return failed("design", "red-contract-invalid", {
1082
+ return failed("artifact", "red-not-witnessed", {
833
1083
  ...details,
834
1084
  excerpt: commandExcerpt(result, secrets),
835
1085
  });
@@ -890,7 +1140,9 @@ export async function preflightCandidate(
890
1140
  let temp: string | undefined;
891
1141
  let checkout: string | undefined;
892
1142
  let targets: string[] = [];
893
- let outcome: CandidatePreflightResult;
1143
+ let outcome: CandidatePreflightResult | undefined;
1144
+ let unknownError: unknown;
1145
+ let hasUnknownError = false;
894
1146
  try {
895
1147
  if (input.signal?.aborted) reject("cancelled", "cancelled");
896
1148
  if (!validSnapshot(input.snapshot)) reject("artifact", "invalid-snapshot");
@@ -1005,6 +1257,9 @@ export async function preflightCandidate(
1005
1257
  reconstructBoundDirectories(input, checkout, validated.dependencyPath);
1006
1258
  reconstructBaseline(input, checkout);
1007
1259
  scanCheckout(checkout);
1260
+ const dependenciesBefore = dependencyContract(
1261
+ path.join(checkout, "package.json"),
1262
+ );
1008
1263
 
1009
1264
  result = await run(
1010
1265
  dependencies,
@@ -1031,6 +1286,14 @@ export async function preflightCandidate(
1031
1286
  if (input.signal?.aborted) reject("cancelled", "cancelled");
1032
1287
  if (result.error) reject("environment", "git-apply-unavailable");
1033
1288
  if (result.status !== 0) reject("artifact", "git-apply-failed");
1289
+ const dependencyManifestChanged = rejectUnapprovedDependencies(
1290
+ dependenciesBefore,
1291
+ dependencyContract(path.join(checkout, "package.json")),
1292
+ input.approvedDependencies,
1293
+ validated.targets.some((target) =>
1294
+ (LOCKFILES as readonly string[]).includes(target),
1295
+ ),
1296
+ );
1034
1297
  await requireRegularGitModes(dependencies, checkout, input.signal);
1035
1298
  scanCheckout(checkout);
1036
1299
 
@@ -1073,6 +1336,39 @@ export async function preflightCandidate(
1073
1336
  excerpt: commandExcerpt(result, [root, checkout]),
1074
1337
  });
1075
1338
 
1339
+ if (dependencyManifestChanged) {
1340
+ const dependencyArgs = sandboxArgs(
1341
+ checkout,
1342
+ dependencySource,
1343
+ input.dependencyTarget.kind === "dir",
1344
+ bunExecutable,
1345
+ [
1346
+ "bun",
1347
+ "install",
1348
+ "--frozen-lockfile",
1349
+ "--dry-run",
1350
+ "--ignore-scripts",
1351
+ "--no-progress",
1352
+ ],
1353
+ );
1354
+ result = await run(
1355
+ dependencies,
1356
+ checkout,
1357
+ bwrap,
1358
+ dependencyArgs,
1359
+ undefined,
1360
+ input.signal,
1361
+ );
1362
+ if (input.signal?.aborted) reject("cancelled", "cancelled");
1363
+ if (result.error || result.status === null)
1364
+ reject("environment", "bubblewrap-launch-failed", {
1365
+ excerpt: commandExcerpt(result, [root, checkout]),
1366
+ });
1367
+ if (result.status !== 0) {
1368
+ reject("approval-boundary", "unapproved-dependency-change");
1369
+ }
1370
+ }
1371
+
1076
1372
  if (!isStaticCheck(input.verification)) {
1077
1373
  const checkArgs = sandboxArgs(
1078
1374
  checkout,
@@ -1144,20 +1440,29 @@ export async function preflightCandidate(
1144
1440
  } catch (error) {
1145
1441
  if (input.signal?.aborted) outcome = failed("cancelled", "cancelled");
1146
1442
  else if (error instanceof PreflightFailure)
1147
- outcome = failed(error.failureClass, error.code, error.details);
1148
- else
1149
- outcome = failed("environment", "preflight-failed", {
1150
- excerpt: String((error as Error).message).slice(0, 256),
1151
- });
1443
+ outcome = {
1444
+ ok: false,
1445
+ ...error.failure,
1446
+ ...error.details,
1447
+ checkoutRemoved: true,
1448
+ } as CandidatePreflightResult;
1449
+ else {
1450
+ unknownError = error;
1451
+ hasUnknownError = true;
1452
+ }
1152
1453
  }
1153
- if (temp === undefined) return outcome;
1154
- const cleanup = removeCheckout(temp, dependencies);
1155
- if (cleanup.failed)
1156
- return failed(
1157
- "environment",
1158
- "checkout-cleanup-failed",
1159
- {},
1160
- cleanup.removed,
1161
- );
1454
+ if (temp !== undefined) {
1455
+ const cleanup = removeCheckout(temp, dependencies);
1456
+ if (cleanup.failed && !hasUnknownError)
1457
+ return failed(
1458
+ "environment",
1459
+ "checkout-cleanup-failed",
1460
+ {},
1461
+ cleanup.removed,
1462
+ );
1463
+ }
1464
+ if (hasUnknownError) throw unknownError;
1465
+ if (outcome === undefined)
1466
+ throw new Error("candidate preflight outcome unavailable");
1162
1467
  return outcome;
1163
1468
  }