@cassiomc1/forgeloop 0.1.15 → 1.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 (52) hide show
  1. package/DOCS_INDEX.md +60 -0
  2. package/EXECUTION_STATE.md +9 -0
  3. package/LOOP_ENGINEERING.md +15 -0
  4. package/LOOP_SYSTEM_DESIGN.md +8 -0
  5. package/PROTOCOL_INTEGRATION.md +8 -0
  6. package/QUALITY_SCORECARD.md +2 -0
  7. package/README.md +183 -654
  8. package/TERMINOLOGY.md +4 -0
  9. package/THREAT_MODEL.md +9 -0
  10. package/docs/assets/forgeloop-flow.svg +1 -0
  11. package/docs/forgeloop-flow.mmd +51 -0
  12. package/package.json +16 -3
  13. package/schemas/continuity.schema.json +57 -0
  14. package/scripts/CI_VALIDATORS.md +32 -0
  15. package/src/cli.js +307 -204
  16. package/src/commands/clear-continuity.js +9 -0
  17. package/src/commands/continuity.js +25 -0
  18. package/src/commands/doctor.js +17 -2
  19. package/src/commands/reconcile-continuity.js +23 -0
  20. package/src/commands/record-continuity.js +63 -0
  21. package/src/commands/status.js +14 -1
  22. package/src/commands/update.js +12 -13
  23. package/src/commands/validate-protocol.js +19 -0
  24. package/src/core/artifacts.js +1 -0
  25. package/src/core/bundles.js +6 -0
  26. package/src/core/command-resolution.js +295 -0
  27. package/src/core/command-tokenizer.js +122 -0
  28. package/src/core/conformance.js +8 -2
  29. package/src/core/continuity-cli-options.js +58 -0
  30. package/src/core/continuity-conformance.js +46 -0
  31. package/src/core/continuity-observability.js +20 -0
  32. package/src/core/continuity-reconciliation.js +224 -0
  33. package/src/core/continuity.js +245 -0
  34. package/src/core/inspect.js +9 -1
  35. package/src/core/installation-authority.js +178 -0
  36. package/src/core/json-safety.js +17 -13
  37. package/src/core/next-action-artifacts.js +118 -0
  38. package/src/core/next-action-continuity.js +65 -0
  39. package/src/core/next-action-model.js +127 -0
  40. package/src/core/next-action-phases.js +12 -0
  41. package/src/core/next-action.js +22 -249
  42. package/src/core/npm-classifier.js +343 -0
  43. package/src/core/package-manager-classifiers.js +37 -0
  44. package/src/core/preflight-consistency.js +221 -0
  45. package/src/core/preflight-loaders.js +112 -0
  46. package/src/core/preflight-model.js +83 -0
  47. package/src/core/preflight.js +34 -444
  48. package/src/core/protocol.js +7 -0
  49. package/src/core/schema-validation.js +15 -1
  50. package/src/core/templates.js +2 -0
  51. package/src/core/verification-capability.js +31 -1106
  52. package/src/core/verification-constants.js +61 -0
@@ -1,4 +1,4 @@
1
- import { assertSafePath, fileExists, ensureWithin, readBytes } from "../core/filesystem.js";
1
+ import { assertSafePath, fileExists, ensureWithin, readBytes, writeFileAtomic } from "../core/filesystem.js";
2
2
  import { readManifest, sha256, writeManifest } from "../core/manifest.js";
3
3
  import { readTemplateEntries } from "../core/templates.js";
4
4
  import { createEvidence } from "../core/evidence.js";
@@ -113,9 +113,10 @@ async function adoptAdapters({ target, manifest, adoptPaths, findings }) {
113
113
  return nextManifest;
114
114
  }
115
115
 
116
- export async function runDoctor({ target, packageRoot, adoptPaths = [], strict = false }) {
116
+ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict = false, fix = false }) {
117
117
  const findings = [];
118
118
  let manifest = null;
119
+ let manifestChanged = false;
119
120
  try {
120
121
  manifest = await readManifest(target);
121
122
  if (!manifest) {
@@ -186,6 +187,16 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
186
187
  ));
187
188
  continue;
188
189
  }
190
+ if (fix && manifest && (layoutVersion >= LAYOUT_VERSION || managedPath.startsWith(".forgeloop/"))) {
191
+ await writeFileAtomic(destination, entry.bytes);
192
+ manifest.files[managedPath] = {
193
+ sha256: sha256(entry.bytes),
194
+ preserve: entry.sourcePath === "PROJECT_PROFILE.md",
195
+ };
196
+ manifestChanged = true;
197
+ findings.push(finding("file-restored", "info", managedPath, "Restored missing managed template file.", "No action required."));
198
+ continue;
199
+ }
189
200
  findings.push(finding("file-missing", "error", managedPath, "Managed file is missing."));
190
201
  continue;
191
202
  }
@@ -280,6 +291,10 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
280
291
  }
281
292
  }
282
293
 
294
+ if (manifestChanged && manifest) {
295
+ await writeManifest(target, manifest);
296
+ }
297
+
283
298
  const ok = findings.every((item) => item.severity !== "error")
284
299
  && (!strict || findings.every((item) => item.severity !== "warning"));
285
300
  return {
@@ -0,0 +1,23 @@
1
+ import { reconcileContinuity } from "../core/continuity-reconciliation.js";
2
+
3
+ export async function runReconcileContinuity({ target, packageRoot } = {}) {
4
+ return reconcileContinuity({ target, packageRoot });
5
+ }
6
+
7
+ export function formatReconcileContinuityResult(result) {
8
+ const lines = [
9
+ `Continuity: ${result.path ?? ".forgeloop/continuity.json"}`,
10
+ `Classification: ${result.classification}`,
11
+ `Task matches: ${result.taskMatches ?? "not-verified"}`,
12
+ `Work state matches: ${result.workStateMatches ?? "not-verified"}`,
13
+ `Contract matches: ${result.contractMatches ?? "not-verified"}`,
14
+ `Phase matches: ${result.phaseMatches ?? "not-verified"}`,
15
+ `Repository: ${result.repositoryComparison}`,
16
+ `Changed paths: ${result.changedPathComparison}`,
17
+ "Authority: OPERATIONAL_CONTEXT_ONLY",
18
+ "Evidence: NONE",
19
+ ];
20
+ if (result.reasonCodes?.length) lines.push(`Reason codes: ${result.reasonCodes.join(", ")}`);
21
+ if (result.reasons?.length) lines.push(`Reasons: ${result.reasons.join(", ")}`);
22
+ return `${lines.join("\n")}\n`;
23
+ }
@@ -0,0 +1,63 @@
1
+ import { writeContinuity } from "../core/continuity.js";
2
+
3
+ function parseWorkItem(value, label) {
4
+ if (typeof value !== "string") throw new Error(`${label} must use <id>:<summary>`);
5
+ const separator = value.indexOf(":");
6
+ if (separator <= 0 || separator === value.length - 1) {
7
+ throw new Error(`${label} must use <id>:<summary>`);
8
+ }
9
+ return {
10
+ id: value.slice(0, separator).trim(),
11
+ summary: value.slice(separator + 1).trim(),
12
+ };
13
+ }
14
+
15
+ export async function runRecordContinuity({
16
+ target,
17
+ packageRoot,
18
+ focusId,
19
+ focusSummary,
20
+ remaining = [],
21
+ knownIssues = [],
22
+ changedAreas = [],
23
+ inspectFirst = [],
24
+ resumeNote,
25
+ state,
26
+ contract,
27
+ repositoryFingerprint,
28
+ now,
29
+ dryRun = false,
30
+ } = {}) {
31
+ if ((focusId && !focusSummary) || (!focusId && focusSummary)) {
32
+ throw new Error("record-continuity requires --focus-id and --focus-summary together");
33
+ }
34
+ return writeContinuity(target, {
35
+ ...(focusId ? { currentFocus: { id: focusId, summary: focusSummary } } : {}),
36
+ remainingWork: remaining.map((item) => parseWorkItem(item, "--remaining")),
37
+ knownIssues: knownIssues.map((item) => parseWorkItem(item, "--known-issue")),
38
+ changedAreas,
39
+ inspectFirst,
40
+ ...(resumeNote ? { resumeNote } : {}),
41
+ }, {
42
+ packageRoot,
43
+ ...(state ? { state } : {}),
44
+ ...(contract ? { contract } : {}),
45
+ ...(repositoryFingerprint ? { repositoryFingerprint } : {}),
46
+ ...(now ? { now } : {}),
47
+ dryRun,
48
+ });
49
+ }
50
+
51
+ export function formatRecordContinuityResult(result) {
52
+ const value = result.value;
53
+ return [
54
+ `Continuity: ${result.path}`,
55
+ `Task: ${value.taskId}`,
56
+ `Phase: ${value.phase}`,
57
+ `Focus: ${value.currentFocus?.id ?? "none"}`,
58
+ `Remaining: ${value.remainingWork.length}`,
59
+ `Known issues: ${value.knownIssues.length}`,
60
+ "Authority: OPERATIONAL_CONTEXT_ONLY",
61
+ "Evidence: NONE",
62
+ ].join("\n") + "\n";
63
+ }
@@ -1,12 +1,17 @@
1
1
  import { readAndClassifyWorkState } from "../core/work-state.js";
2
2
  import { inspectSchemaHealth } from "../core/schema-validation.js";
3
+ import { reconcileContinuity } from "../core/continuity-reconciliation.js";
3
4
 
4
5
  export async function runStatus({ target, packageRoot, contractFile = null }) {
5
6
  const state = await readAndClassifyWorkState({ target, packageRoot, contractFile });
6
- const protocol = await inspectSchemaHealth(target);
7
+ const [protocol, continuity] = await Promise.all([
8
+ inspectSchemaHealth(target),
9
+ reconcileContinuity({ target, packageRoot }),
10
+ ]);
7
11
  return {
8
12
  ...state,
9
13
  protocol,
14
+ continuity,
10
15
  evidence: [...(state.evidence ?? []), ...(protocol.evidence ?? [])],
11
16
  };
12
17
  }
@@ -24,6 +29,14 @@ export function formatStatusResult(result) {
24
29
  if (result.contractComparison) lines.push(`Contract: ${result.contractComparison}`);
25
30
  if (result.artifactComparison) lines.push(`Artifacts: ${result.artifactComparison}`);
26
31
  if (result.protocol) lines.push(`Schemas: ${result.protocol.status}`);
32
+ if (result.continuity) {
33
+ const continuity = result.continuity.continuity;
34
+ lines.push(`Continuity: ${result.continuity.classification}`);
35
+ lines.push(`Continuity focus: ${continuity?.currentFocus?.id ?? "none"}`);
36
+ lines.push(`Continuity remaining: ${continuity?.remainingWork?.length ?? 0}`);
37
+ lines.push(`Continuity known issues: ${continuity?.knownIssues?.length ?? 0}`);
38
+ lines.push(`Continuity authority: ${result.continuity.authority ?? "OPERATIONAL_CONTEXT_ONLY"}`);
39
+ }
27
40
  if (result.error) lines.push(`Error: ${result.error}`);
28
41
  return `${lines.join("\n")}\n`;
29
42
  }
@@ -9,9 +9,8 @@ import {
9
9
  writeManifest,
10
10
  } from "../core/manifest.js";
11
11
  import { readTemplateEntries } from "../core/templates.js";
12
- import { isNativeAdapterPath, LAYOUT_VERSION } from "../core/target-layout.js";
12
+ import { isNativeAdapterPath, LAYOUT_VERSION, LEGACY_PROFILE_PATH } from "../core/target-layout.js";
13
13
 
14
- const PROFILE_PATH = "PROJECT_PROFILE.md";
15
14
  const LEGACY_CLEANUP_DIRECTORIES = Object.freeze(["ENG", "schemas"]);
16
15
 
17
16
  function migrationConflict(code, path, message) {
@@ -169,7 +168,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
169
168
  const legacyHash = legacyBytes ? sha256(legacyBytes) : null;
170
169
  const unchangedManagedLegacy = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
171
170
 
172
- if (entry.sourcePath === PROFILE_PATH
171
+ if (entry.sourcePath === LEGACY_PROFILE_PATH
173
172
  && legacyExists
174
173
  && legacyRecord
175
174
  && currentHash === legacyHash) {
@@ -179,7 +178,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
179
178
  continue;
180
179
  }
181
180
 
182
- if (!hiddenMatchesSource && !destinationRecord && entry.sourcePath !== PROFILE_PATH) {
181
+ if (!hiddenMatchesSource && !destinationRecord && entry.sourcePath !== LEGACY_PROFILE_PATH) {
183
182
  conflicts.push(migrationConflict(
184
183
  "E_HIDDEN_KIT_MIGRATION_CONFLICT",
185
184
  entry.relativePath,
@@ -191,12 +190,12 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
191
190
  addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
192
191
  nextManifest.files[entry.relativePath] = manifestRecord(
193
192
  sourceHash,
194
- entry.sourcePath === PROFILE_PATH,
193
+ entry.sourcePath === LEGACY_PROFILE_PATH,
195
194
  legacyHash,
196
195
  );
197
196
  continue;
198
197
  } else if (hiddenMatchesSource && legacyExists) {
199
- const conflict = entry.sourcePath === PROFILE_PATH
198
+ const conflict = entry.sourcePath === LEGACY_PROFILE_PATH
200
199
  ? migrationConflict(
201
200
  "E_PROFILE_MIGRATION_CONFLICT",
202
201
  entry.legacyRelativePath,
@@ -214,7 +213,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
214
213
  }
215
214
  nextManifest.files[entry.relativePath] = manifestRecord(
216
215
  currentHash,
217
- entry.sourcePath === PROFILE_PATH
216
+ entry.sourcePath === LEGACY_PROFILE_PATH
218
217
  || !destinationRecord
219
218
  || Boolean(destinationRecord.preserve),
220
219
  );
@@ -224,7 +223,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
224
223
  if (!legacyExists) {
225
224
  addAction(actions, dryRun, "create", entry.relativePath);
226
225
  writes.push({ destination, bytes: entry.bytes });
227
- nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
226
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === LEGACY_PROFILE_PATH);
228
227
  continue;
229
228
  }
230
229
 
@@ -232,7 +231,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
232
231
  const legacyHash = sha256(legacyBytes);
233
232
  const unchangedManaged = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
234
233
 
235
- if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
234
+ if (entry.sourcePath === LEGACY_PROFILE_PATH && legacyRecord) {
236
235
  addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
237
236
  writes.push({ destination, bytes: legacyBytes });
238
237
  addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
@@ -256,7 +255,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
256
255
  continue;
257
256
  }
258
257
 
259
- const conflict = entry.sourcePath === PROFILE_PATH
258
+ const conflict = entry.sourcePath === LEGACY_PROFILE_PATH
260
259
  ? migrationConflict(
261
260
  "E_PROFILE_MIGRATION_CONFLICT",
262
261
  entry.legacyRelativePath,
@@ -275,7 +274,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
275
274
  reason: legacyRecord ? "managed-modified" : "unmanaged",
276
275
  });
277
276
  writes.push({ destination, bytes: entry.bytes });
278
- nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
277
+ nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === LEGACY_PROFILE_PATH);
279
278
  }
280
279
 
281
280
  // Apply all hidden writes and verify their bytes before changing manifest authority.
@@ -364,7 +363,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, h
364
363
  entry,
365
364
  record: {
366
365
  sha256: sourceHash,
367
- preserve: entry.sourcePath === PROFILE_PATH,
366
+ preserve: entry.sourcePath === LEGACY_PROFILE_PATH,
368
367
  ...(record?.legacySha256 ? { legacySha256: record.legacySha256 } : {}),
369
368
  },
370
369
  });
@@ -376,7 +375,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, h
376
375
  continue;
377
376
  }
378
377
 
379
- if (record.preserve || entry.sourcePath === PROFILE_PATH) {
378
+ if (record.preserve || entry.sourcePath === LEGACY_PROFILE_PATH) {
380
379
  actions.push({ action: "skip", path: entry.relativePath, reason: "preserved" });
381
380
  continue;
382
381
  }
@@ -10,6 +10,8 @@ import { ARTIFACT_PATHS, readJsonArtifact } from "../core/artifacts.js";
10
10
  import { evaluatePreflight, validateReadyProtocolConsistency } from "../core/preflight.js";
11
11
  import { validateEventLedger, validateStateLedgerCoherence } from "../core/events.js";
12
12
  import { validateChecksExecutionProvenance } from "../core/completion-artifacts.js";
13
+ import { assertContinuitySemantics } from "../core/continuity.js";
14
+ import { currentChangedPaths, currentRepositoryFingerprint } from "../core/repository.js";
13
15
 
14
16
  async function readArtifact(target, relativePath, label) {
15
17
  if (!relativePath) return null;
@@ -48,6 +50,7 @@ export async function runValidateProtocol({
48
50
  stateFile = null,
49
51
  receiptFile = null,
50
52
  contractFile = null,
53
+ continuityFile = null,
51
54
  taskBriefFiles = [],
52
55
  delegatedResultFiles = [],
53
56
  }) {
@@ -55,6 +58,7 @@ export async function runValidateProtocol({
55
58
  ["route", routeFile],
56
59
  ["state", stateFile],
57
60
  ["receipt", receiptFile],
61
+ ["continuity", continuityFile],
58
62
  ...taskBriefFiles.map((file) => [`task brief:${file}`, file]),
59
63
  ...delegatedResultFiles.map((file) => [`delegated result:${file}`, file]),
60
64
  ];
@@ -68,6 +72,7 @@ export async function runValidateProtocol({
68
72
  const route = loaded.find((item) => item.label === "route")?.value ?? null;
69
73
  const state = loaded.find((item) => item.label === "state")?.value ?? null;
70
74
  const receipt = loaded.find((item) => item.label === "receipt")?.value ?? null;
75
+ const continuity = loaded.find((item) => item.label === "continuity")?.value ?? null;
71
76
  const taskBriefs = loaded.filter((item) => item.label.startsWith("task brief:") && item.value).map((item) => item.value);
72
77
  const delegatedResults = loaded.filter((item) => item.label.startsWith("delegated result:") && item.value).map((item) => item.value);
73
78
  const schemaErrors = [];
@@ -88,6 +93,7 @@ export async function runValidateProtocol({
88
93
  await validateLoaded(loaded.find((item) => item.label === "route"), "routing-result", async (value) => assertRouteInvariants(value));
89
94
  await validateLoaded(loaded.find((item) => item.label === "state"), "work-state", async (value) => assertWorkStateSemantics(value));
90
95
  await validateLoaded(loaded.find((item) => item.label === "receipt"), "execution-receipt", async (value) => validateReceipt(value, packageRoot));
96
+ await validateLoaded(loaded.find((item) => item.label === "continuity"), "continuity", async (value) => assertContinuitySemantics(value));
91
97
  for (const [value, artifactPath] of [
92
98
  [state, stateFile],
93
99
  [receipt, receiptFile],
@@ -142,11 +148,20 @@ export async function runValidateProtocol({
142
148
  })),
143
149
  ];
144
150
  }
151
+ const continuityContext = continuity && state && !stateValidationError
152
+ ? {
153
+ contractFingerprint: state.contractFingerprint,
154
+ repositoryFingerprint: await currentRepositoryFingerprint(target),
155
+ changedPaths: await currentChangedPaths(target),
156
+ }
157
+ : {};
145
158
  const result = validateTaskArtifactSet({
146
159
  route,
147
160
  state,
148
161
  stateClassification,
149
162
  receipt,
163
+ continuity,
164
+ continuityContext,
150
165
  taskBriefs,
151
166
  delegatedResults,
152
167
  events: ledgerEvents,
@@ -164,6 +179,10 @@ export async function runValidateProtocol({
164
179
 
165
180
  export function formatValidateProtocolResult(result) {
166
181
  const lines = [`Protocol: ${result.status}`];
182
+ if (result.continuity) {
183
+ lines.push(`Continuity: ${result.continuity.status}`);
184
+ lines.push(`Continuity authority: ${result.continuity.authority}`);
185
+ }
167
186
  if (result.stale) {
168
187
  lines.push(`Repository: ${result.stale.repositoryComparison}`);
169
188
  lines.push(`Contract: ${result.stale.contractComparison}`);
@@ -16,6 +16,7 @@ export const ARTIFACT_PATHS = Object.freeze({
16
16
  config: ".forgeloop/config.json",
17
17
  gates: ".forgeloop/gates",
18
18
  state: ".forgeloop/work-state.json",
19
+ continuity: ".forgeloop/continuity.json",
19
20
  receipt: ".forgeloop/execution-receipt.json",
20
21
  executionDirectory: ".forgeloop/executions",
21
22
  });
@@ -6,6 +6,7 @@ import { assertSafePath, ensureWithin, fileExists, readBytes, writeFileAtomic }
6
6
  import { PROTOCOL_VERSION } from "./protocol.js";
7
7
  import { validateChecksExecutionProvenance } from "./completion-artifacts.js";
8
8
  import { readExecutionArtifact } from "./execution.js";
9
+ import { assertContinuitySemantics } from "./continuity.js";
9
10
 
10
11
  export const BUNDLE_SCHEMA_VERSION = 1;
11
12
  const BUNDLE_ROOT = ".forgeloop/tasks";
@@ -89,6 +90,7 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
89
90
  [ARTIFACT_PATHS.receipt, "receipt.json", "execution-receipt"],
90
91
  [ARTIFACT_PATHS.sources, "sources.json", "source-registry"],
91
92
  [ARTIFACT_PATHS.config, "config.json", "config"],
93
+ [ARTIFACT_PATHS.continuity, "continuity.json", "continuity"],
92
94
  ];
93
95
  for (const [sourcePath, destinationName, schemaName] of optional) {
94
96
  const copied = await copyJson(target, sourcePath, `${directory}/${destinationName}`, schemaName, packageRoot, artifacts, destinationName);
@@ -146,6 +148,7 @@ export async function readTaskBundle(target, taskId, packageRoot) {
146
148
  "receipt.json": ["receipt", "execution-receipt"],
147
149
  "sources.json": ["sources", "source-registry"],
148
150
  "config.json": ["config", "config"],
151
+ "continuity.json": ["continuity", "continuity"],
149
152
  };
150
153
  const executions = {};
151
154
  for (const artifact of manifest.value.artifacts) {
@@ -160,6 +163,9 @@ export async function readTaskBundle(target, taskId, packageRoot) {
160
163
  if (mapping[1] === "current-contract") {
161
164
  await validateContract(loadedArtifact.value, packageRoot);
162
165
  }
166
+ if (mapping[1] === "continuity") {
167
+ assertContinuitySemantics(loadedArtifact.value);
168
+ }
163
169
  loaded[mapping[0]] = loadedArtifact.value;
164
170
  }
165
171
  if (Object.keys(executions).length > 0) loaded.executions = executions;
@@ -0,0 +1,295 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ extractToolFromArgs,
6
+ normalizeExecutableName,
7
+ splitCommandPipeline,
8
+ tokenizeCommand,
9
+ } from "./command-tokenizer.js";
10
+ import {
11
+ classifyNpmInvocation,
12
+ getNpmLifecycleCandidates,
13
+ getNpmScriptName,
14
+ parseNpmInvocation,
15
+ parseNpmInvocationArgs,
16
+ } from "./npm-classifier.js";
17
+ import {
18
+ classifyPnpmInvocation,
19
+ classifyYarnInvocation,
20
+ } from "./package-manager-classifiers.js";
21
+
22
+ function classifySingleCommand(commandInput) {
23
+ const tokens = Array.isArray(commandInput) ? [...commandInput] : tokenizeCommand(commandInput);
24
+ if (tokens.length === 0) return { resolutionMode: "UNKNOWN", mayInstall: false, installer: null, tool: null };
25
+
26
+ let i = 0;
27
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
28
+ if (i >= tokens.length) return { resolutionMode: "UNKNOWN", mayInstall: false, installer: null, tool: null };
29
+
30
+ const binaryToken = tokens[i];
31
+ const binary = normalizeExecutableName(binaryToken);
32
+ const rest = tokens.slice(i + 1);
33
+
34
+ if (binary === "call") return classifyCommandResolution(rest);
35
+
36
+ if (binaryToken.includes("node_modules/.bin/") || binaryToken.startsWith("./node_modules/")) {
37
+ return { resolutionMode: "LOCAL_PACKAGE_BINARY", mayInstall: false, installer: null, tool: binary };
38
+ }
39
+
40
+ if (binary === "npx") {
41
+ if (rest.some((arg) => arg === "--no-install" || arg === "--no")) {
42
+ const nonInstallArgs = rest.filter((arg) => arg !== "--no-install" && arg !== "--no");
43
+ return {
44
+ resolutionMode: "NON_INSTALLING_RESOLUTION",
45
+ mayInstall: false,
46
+ installer: "npx",
47
+ tool: extractToolFromArgs(nonInstallArgs),
48
+ };
49
+ }
50
+ return {
51
+ resolutionMode: "INSTALL_CAPABLE_RESOLUTION",
52
+ mayInstall: true,
53
+ installer: "npx",
54
+ tool: extractToolFromArgs(rest),
55
+ };
56
+ }
57
+
58
+ if (binary === "pnpx" || binary === "bunx" || binary === "uvx") {
59
+ return {
60
+ resolutionMode: "INSTALL_CAPABLE_RESOLUTION",
61
+ mayInstall: true,
62
+ installer: binary,
63
+ tool: extractToolFromArgs(rest),
64
+ };
65
+ }
66
+
67
+ if (binary === "pnpm") return classifyPnpmInvocation(rest);
68
+ if (binary === "yarn") return classifyYarnInvocation(rest);
69
+
70
+ if (binary === "bun") {
71
+ if (rest[0] === "x") {
72
+ return {
73
+ resolutionMode: "INSTALL_CAPABLE_RESOLUTION",
74
+ mayInstall: true,
75
+ installer: "bun x",
76
+ tool: extractToolFromArgs(rest.slice(1)),
77
+ };
78
+ }
79
+ if (["add", "install", "i"].includes(rest[0])) {
80
+ return {
81
+ resolutionMode: "EXPLICIT_INSTALLATION",
82
+ mayInstall: true,
83
+ installer: "bun",
84
+ tool: extractToolFromArgs(rest.slice(1)),
85
+ };
86
+ }
87
+ return { resolutionMode: "LOCAL_PACKAGE_BINARY", mayInstall: false, installer: null, tool: null };
88
+ }
89
+
90
+ if (binary === "uv") {
91
+ if (rest[0] === "tool" && rest[1] === "run") {
92
+ return {
93
+ resolutionMode: "INSTALL_CAPABLE_RESOLUTION",
94
+ mayInstall: true,
95
+ installer: "uv tool run",
96
+ tool: extractToolFromArgs(rest.slice(2)),
97
+ };
98
+ }
99
+ if (rest[0] === "pip" && rest[1] === "install") {
100
+ return {
101
+ resolutionMode: "EXPLICIT_INSTALLATION",
102
+ mayInstall: true,
103
+ installer: "uv pip install",
104
+ tool: extractToolFromArgs(rest.slice(2)),
105
+ };
106
+ }
107
+ if (rest[0] === "add") {
108
+ return {
109
+ resolutionMode: "EXPLICIT_INSTALLATION",
110
+ mayInstall: true,
111
+ installer: "uv add",
112
+ tool: extractToolFromArgs(rest.slice(1)),
113
+ };
114
+ }
115
+ return { resolutionMode: "LOCAL_EXECUTABLE", mayInstall: false, installer: null, tool: null };
116
+ }
117
+
118
+ if (binary === "pipx") {
119
+ if (rest[0] === "run") {
120
+ return {
121
+ resolutionMode: "INSTALL_CAPABLE_RESOLUTION",
122
+ mayInstall: true,
123
+ installer: "pipx run",
124
+ tool: extractToolFromArgs(rest.slice(1)),
125
+ };
126
+ }
127
+ if (rest[0] === "install") {
128
+ return {
129
+ resolutionMode: "EXPLICIT_INSTALLATION",
130
+ mayInstall: true,
131
+ installer: "pipx install",
132
+ tool: extractToolFromArgs(rest.slice(1)),
133
+ };
134
+ }
135
+ return { resolutionMode: "LOCAL_EXECUTABLE", mayInstall: false, installer: null, tool: null };
136
+ }
137
+
138
+ if (binary === "npm") return classifyNpmInvocation(parseNpmInvocationArgs(rest));
139
+
140
+ if (binary === "pip" || binary === "pip3") {
141
+ if (rest[0] === "install") {
142
+ return {
143
+ resolutionMode: "EXPLICIT_INSTALLATION",
144
+ mayInstall: true,
145
+ installer: binary,
146
+ tool: extractToolFromArgs(rest.slice(1)),
147
+ };
148
+ }
149
+ }
150
+ if (binary === "python" || binary === "python3") {
151
+ if (rest[0] === "-m" && rest[1] === "pip" && rest[2] === "install") {
152
+ return {
153
+ resolutionMode: "EXPLICIT_INSTALLATION",
154
+ mayInstall: true,
155
+ installer: `${binary} -m pip install`,
156
+ tool: extractToolFromArgs(rest.slice(3)),
157
+ };
158
+ }
159
+ }
160
+
161
+ if (binary === "cargo") {
162
+ if (rest[0] === "install" || rest[0] === "binstall") {
163
+ return {
164
+ resolutionMode: "EXPLICIT_INSTALLATION",
165
+ mayInstall: true,
166
+ installer: binary,
167
+ tool: extractToolFromArgs(rest.slice(1)),
168
+ };
169
+ }
170
+ }
171
+
172
+ if (["brew", "apt", "apt-get", "apk", "dnf", "pacman"].includes(binary)) {
173
+ if (["install", "add", "-S"].includes(rest[0])) {
174
+ return {
175
+ resolutionMode: "EXPLICIT_INSTALLATION",
176
+ mayInstall: true,
177
+ installer: binary,
178
+ tool: extractToolFromArgs(rest.slice(1)),
179
+ };
180
+ }
181
+ }
182
+
183
+ return { resolutionMode: "LOCAL_EXECUTABLE", mayInstall: false, installer: null, tool: null };
184
+ }
185
+
186
+ export function classifyCommandResolution(commandInput) {
187
+ if (Array.isArray(commandInput)) {
188
+ if (commandInput.length === 0 || commandInput.some((item) => typeof item !== "string" || item.trim() === "")) {
189
+ return { resolutionMode: "UNKNOWN", mayInstall: false, installer: null, tool: null };
190
+ }
191
+ const binary = normalizeExecutableName(commandInput[0]);
192
+ if (["sh", "bash", "zsh", "dash", "ksh"].includes(binary)) {
193
+ const shellFlagIndex = commandInput.findIndex((item, index) => index > 0 && /^-.*c/.test(item));
194
+ const shellCommand = shellFlagIndex >= 0 ? commandInput[shellFlagIndex + 1] : null;
195
+ if (shellCommand) return classifyCommandResolution(shellCommand);
196
+ }
197
+ if (binary === "cmd") {
198
+ const shellFlagIndex = commandInput.findIndex((item, index) => index > 0 && /^\/c$/iu.test(item));
199
+ const shellCommand = shellFlagIndex >= 0 ? commandInput.slice(shellFlagIndex + 1).join(" ") : null;
200
+ if (shellCommand) return classifyCommandResolution(shellCommand);
201
+ }
202
+ return classifySingleCommand(commandInput);
203
+ }
204
+
205
+ if (typeof commandInput !== "string" || commandInput.trim() === "") {
206
+ return { resolutionMode: "UNKNOWN", mayInstall: false, installer: null, tool: null };
207
+ }
208
+
209
+ const subcommands = splitCommandPipeline(commandInput);
210
+ for (const subcommand of subcommands) {
211
+ const result = classifySingleCommand(subcommand);
212
+ if (result.mayInstall) return result;
213
+ }
214
+ return classifySingleCommand(subcommands[0] || commandInput);
215
+ }
216
+
217
+ export const MAX_NPM_SCRIPT_DEPTH = 16;
218
+
219
+ async function resolveNpmScriptRisk({ scriptName, packageJson, visited = new Set(), depth = 0 } = {}) {
220
+ if (depth > MAX_NPM_SCRIPT_DEPTH) {
221
+ return { resolutionMode: "UNKNOWN", mayInstall: true, installer: "npm-script", tool: null, reason: "MAX_SCRIPT_DEPTH" };
222
+ }
223
+ if (visited.has(scriptName)) {
224
+ return { resolutionMode: "UNKNOWN", mayInstall: true, installer: "npm-script", tool: null, reason: "SCRIPT_CYCLE" };
225
+ }
226
+
227
+ const nextVisited = new Set(visited);
228
+ nextVisited.add(scriptName);
229
+ const candidates = getNpmLifecycleCandidates({ scriptName, scripts: packageJson?.scripts ?? {} });
230
+ for (const candidate of candidates) {
231
+ const scriptBody = packageJson?.scripts?.[candidate];
232
+ if (typeof scriptBody !== "string" || scriptBody.trim() === "") continue;
233
+
234
+ const direct = classifyCommandResolution(scriptBody);
235
+ if (direct.mayInstall) {
236
+ return {
237
+ resolutionMode: direct.resolutionMode,
238
+ mayInstall: true,
239
+ installer: direct.installer ?? "npm-script",
240
+ tool: direct.tool,
241
+ dispatch: { kind: "npm-script", scriptName: candidate },
242
+ };
243
+ }
244
+
245
+ for (const subcommand of splitCommandPipeline(scriptBody)) {
246
+ const nestedScriptName = getNpmScriptName(subcommand);
247
+ if (!nestedScriptName) continue;
248
+ const nested = await resolveNpmScriptRisk({
249
+ scriptName: nestedScriptName,
250
+ packageJson,
251
+ visited: nextVisited,
252
+ depth: depth + 1,
253
+ });
254
+ if (nested?.mayInstall) return nested;
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ async function readPackageJsonIfPresent(cwd) {
261
+ if (!cwd || typeof cwd !== "string") return null;
262
+ try {
263
+ const raw = await fs.readFile(path.resolve(cwd, "package.json"), "utf8");
264
+ return JSON.parse(raw);
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ export async function resolveExecutionResolution({ argv, cwd } = {}) {
271
+ const direct = classifyCommandResolution(argv);
272
+ if (direct.mayInstall && direct.resolutionMode !== "UNKNOWN") return direct;
273
+
274
+ const npmInvocation = parseNpmInvocation(argv);
275
+ const scriptName = getNpmScriptName(argv);
276
+ if (scriptName && (npmInvocation.workspace || npmInvocation.workspaces)) {
277
+ return {
278
+ resolutionMode: "UNKNOWN",
279
+ mayInstall: true,
280
+ installer: "npm-workspace",
281
+ tool: null,
282
+ reason: "NPM_WORKSPACE_SCRIPT_UNRESOLVED",
283
+ dispatch: { kind: "npm-workspace-script", scriptName },
284
+ };
285
+ }
286
+
287
+ if (direct.mayInstall) return direct;
288
+ if (!scriptName) return direct;
289
+
290
+ const packageJson = await readPackageJsonIfPresent(cwd);
291
+ if (!packageJson || typeof packageJson.scripts !== "object" || packageJson.scripts === null) return direct;
292
+
293
+ const nestedRisk = await resolveNpmScriptRisk({ scriptName, packageJson });
294
+ return nestedRisk?.mayInstall ? nestedRisk : direct;
295
+ }