@aperant/framework 0.10.0 → 0.12.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.
- package/CHANGELOG.md +236 -1
- package/agents/apt-planner.md +15 -0
- package/agents/apt-verifier.md +15 -10
- package/bin/apt-proof-video.mjs +256 -76
- package/dist/cli/commands/apply-recipe.d.mts +6 -0
- package/dist/cli/commands/apply-recipe.d.mts.map +1 -0
- package/dist/cli/commands/apply-recipe.mjs +98 -0
- package/dist/cli/commands/apply-recipe.mjs.map +1 -0
- package/dist/cli/commands/catch-up.d.mts +9 -0
- package/dist/cli/commands/catch-up.d.mts.map +1 -0
- package/dist/cli/commands/catch-up.mjs +64 -0
- package/dist/cli/commands/catch-up.mjs.map +1 -0
- package/dist/cli/commands/mobile-prepare.d.mts +53 -0
- package/dist/cli/commands/mobile-prepare.d.mts.map +1 -0
- package/dist/cli/commands/mobile-prepare.mjs +139 -0
- package/dist/cli/commands/mobile-prepare.mjs.map +1 -0
- package/dist/cli/commands/pr-review.d.mts +16 -4
- package/dist/cli/commands/pr-review.d.mts.map +1 -1
- package/dist/cli/commands/pr-review.mjs +30 -11
- package/dist/cli/commands/pr-review.mjs.map +1 -1
- package/dist/cli/commands/recipe-diff.d.mts +6 -0
- package/dist/cli/commands/recipe-diff.d.mts.map +1 -0
- package/dist/cli/commands/recipe-diff.mjs +75 -0
- package/dist/cli/commands/recipe-diff.mjs.map +1 -0
- package/dist/cli/commands/roadmap.d.mts.map +1 -1
- package/dist/cli/commands/roadmap.mjs +53 -0
- package/dist/cli/commands/roadmap.mjs.map +1 -1
- package/dist/cli/commands/route.d.mts.map +1 -1
- package/dist/cli/commands/route.mjs +92 -2
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/commands/verify-sandbox.d.mts +6 -0
- package/dist/cli/commands/verify-sandbox.d.mts.map +1 -0
- package/dist/cli/commands/verify-sandbox.mjs +200 -0
- package/dist/cli/commands/verify-sandbox.mjs.map +1 -0
- package/dist/cli/config/share-policy.d.mts.map +1 -1
- package/dist/cli/config/share-policy.mjs +1 -0
- package/dist/cli/config/share-policy.mjs.map +1 -1
- package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
- package/dist/cli/config/upgrade-gitignore.mjs +1 -0
- package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
- package/dist/cli/consistency/parse-qa.d.mts.map +1 -1
- package/dist/cli/consistency/parse-qa.mjs +18 -4
- package/dist/cli/consistency/parse-qa.mjs.map +1 -1
- package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
- package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +11 -10
- package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
- package/dist/cli/coordination/catch-up.d.mts +26 -0
- package/dist/cli/coordination/catch-up.d.mts.map +1 -0
- package/dist/cli/coordination/catch-up.mjs +239 -0
- package/dist/cli/coordination/catch-up.mjs.map +1 -0
- package/dist/cli/coordination/last-seen.d.mts +45 -0
- package/dist/cli/coordination/last-seen.d.mts.map +1 -0
- package/dist/cli/coordination/last-seen.mjs +128 -0
- package/dist/cli/coordination/last-seen.mjs.map +1 -0
- package/dist/cli/coordination/store.d.mts +15 -0
- package/dist/cli/coordination/store.d.mts.map +1 -1
- package/dist/cli/coordination/store.mjs +16 -0
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/coverage-check/user-outcomes.d.mts +7 -2
- package/dist/cli/coverage-check/user-outcomes.d.mts.map +1 -1
- package/dist/cli/coverage-check/user-outcomes.mjs +50 -17
- package/dist/cli/coverage-check/user-outcomes.mjs.map +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +30 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/gates/verify-approved.d.mts +0 -48
- package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
- package/dist/cli/gate/gates/verify-approved.mjs +23 -80
- package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
- package/dist/cli/git/default-branch.d.mts +51 -0
- package/dist/cli/git/default-branch.d.mts.map +1 -0
- package/dist/cli/git/default-branch.mjs +234 -0
- package/dist/cli/git/default-branch.mjs.map +1 -0
- package/dist/cli/git/identity.d.mts +3 -5
- package/dist/cli/git/identity.d.mts.map +1 -1
- package/dist/cli/git/identity.mjs +10 -4
- package/dist/cli/git/identity.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts.map +1 -1
- package/dist/cli/install/legacy-paths.mjs +2 -0
- package/dist/cli/install/legacy-paths.mjs.map +1 -1
- package/dist/cli/install/mcp-server-specs.d.mts +6 -5
- package/dist/cli/install/mcp-server-specs.d.mts.map +1 -1
- package/dist/cli/install/mcp-server-specs.mjs +22 -4
- package/dist/cli/install/mcp-server-specs.mjs.map +1 -1
- package/dist/cli/roadmap/backend-adapter.d.mts +43 -0
- package/dist/cli/roadmap/backend-adapter.d.mts.map +1 -0
- package/dist/cli/roadmap/backend-adapter.mjs +222 -0
- package/dist/cli/roadmap/backend-adapter.mjs.map +1 -0
- package/dist/cli/util/runtime-capabilities.d.mts +7 -0
- package/dist/cli/util/runtime-capabilities.d.mts.map +1 -1
- package/dist/cli/util/runtime-capabilities.mjs +65 -0
- package/dist/cli/util/runtime-capabilities.mjs.map +1 -1
- package/dist/cli/verify-proof/manifest-validator.mjs +13 -0
- package/dist/cli/verify-proof/manifest-validator.mjs.map +1 -1
- package/dist/cli/verify-proof/resolver.d.mts +10 -7
- package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
- package/dist/cli/verify-proof/resolver.mjs +28 -7
- package/dist/cli/verify-proof/resolver.mjs.map +1 -1
- package/dist/cli/verify-proof/sandbox/apply.d.mts +64 -0
- package/dist/cli/verify-proof/sandbox/apply.d.mts.map +1 -0
- package/dist/cli/verify-proof/sandbox/apply.mjs +379 -0
- package/dist/cli/verify-proof/sandbox/apply.mjs.map +1 -0
- package/dist/cli/verify-proof/sandbox/gate-predicate.d.mts +63 -0
- package/dist/cli/verify-proof/sandbox/gate-predicate.d.mts.map +1 -0
- package/dist/cli/verify-proof/sandbox/gate-predicate.mjs +115 -0
- package/dist/cli/verify-proof/sandbox/gate-predicate.mjs.map +1 -0
- package/dist/cli/verify-proof/sandbox/pattern-matcher.d.mts +57 -0
- package/dist/cli/verify-proof/sandbox/pattern-matcher.d.mts.map +1 -0
- package/dist/cli/verify-proof/sandbox/pattern-matcher.mjs +124 -0
- package/dist/cli/verify-proof/sandbox/pattern-matcher.mjs.map +1 -0
- package/dist/cli/verify-proof/sandbox/recipe-diff.d.mts +28 -0
- package/dist/cli/verify-proof/sandbox/recipe-diff.d.mts.map +1 -0
- package/dist/cli/verify-proof/sandbox/recipe-diff.mjs +190 -0
- package/dist/cli/verify-proof/sandbox/recipe-diff.mjs.map +1 -0
- package/dist/cli/verify-proof/sandbox/sandbox-config.d.mts +25 -0
- package/dist/cli/verify-proof/sandbox/sandbox-config.d.mts.map +1 -0
- package/dist/cli/verify-proof/sandbox/sandbox-config.mjs +85 -0
- package/dist/cli/verify-proof/sandbox/sandbox-config.mjs.map +1 -0
- package/dist/cli/verify-proof/sandbox/self-test.d.mts +39 -0
- package/dist/cli/verify-proof/sandbox/self-test.d.mts.map +1 -0
- package/dist/cli/verify-proof/sandbox/self-test.mjs +248 -0
- package/dist/cli/verify-proof/sandbox/self-test.mjs.map +1 -0
- package/dist/plugin/.claude-plugin/plugin.json +2 -1
- package/dist/plugin/agents/apt-planner.md +15 -0
- package/dist/plugin/agents/apt-verifier.md +15 -10
- package/dist/plugin/skills/apt/SKILL.md +29 -0
- package/dist/plugin/skills/apt-catch-up/SKILL.md +79 -0
- package/dist/plugin/skills/apt-plan/SKILL.md +23 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +5 -4
- package/dist/plugin/skills/apt-run/SKILL.md +1 -1
- package/dist/plugin/skills/apt-setup/SKILL.md +132 -0
- package/dist/plugin/skills/apt-verify/SKILL.md +20 -15
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +146 -11
- package/dist/proof-report.d.ts.map +1 -1
- package/dist/proof-report.js +4 -1
- package/dist/proof-report.js.map +1 -1
- package/dist/types/config.d.ts +42 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/qa-scoring.d.ts +45 -9
- package/dist/types/qa-scoring.d.ts.map +1 -1
- package/dist/types/qa-scoring.js +34 -13
- package/dist/types/qa-scoring.js.map +1 -1
- package/dist/types/state.d.ts +7 -2
- package/dist/types/state.d.ts.map +1 -1
- package/dist/types/task-record.d.ts +10 -2
- package/dist/types/task-record.d.ts.map +1 -1
- package/drivers/mobile/README.md +40 -0
- package/drivers/mobile/driver.mjs +106 -0
- package/drivers/mobile/manifest.json +49 -0
- package/package.json +1 -1
- package/prompts/inbox-clarification.md +11 -0
- package/prompts/inbox-triage.md +20 -0
- package/prompts/qa_orchestrator_agentic.md +18 -17
- package/prompts/qa_reviewer.md +17 -15
- package/skills/apt/SKILL.md +29 -0
- package/skills/apt-catch-up/SKILL.md +79 -0
- package/skills/apt-plan/SKILL.md +23 -0
- package/skills/apt-pr-review/SKILL.md +5 -4
- package/skills/apt-run/SKILL.md +1 -1
- package/skills/apt-setup/SKILL.md +132 -0
- package/skills/apt-verifier.md +7 -4
- package/skills/apt-verify/SKILL.md +20 -15
- package/skills/apt-verify-proof/SKILL.md +146 -11
- package/src/cli/commands/apply-recipe.mjs +105 -0
- package/src/cli/commands/catch-up.mjs +67 -0
- package/src/cli/commands/mobile-prepare.mjs +151 -0
- package/src/cli/commands/pr-review.mjs +32 -11
- package/src/cli/commands/recipe-diff.mjs +81 -0
- package/src/cli/commands/roadmap.mjs +54 -0
- package/src/cli/commands/route.mjs +92 -1
- package/src/cli/commands/verify-sandbox.mjs +231 -0
- package/src/cli/config/share-policy.mjs +1 -0
- package/src/cli/config/upgrade-gitignore.mjs +1 -0
- package/src/cli/consistency/parse-qa.mjs +20 -4
- package/src/cli/consistency/rules/r5-verdict-consistency.mjs +11 -13
- package/src/cli/coordination/catch-up.mjs +231 -0
- package/src/cli/coordination/last-seen.mjs +131 -0
- package/src/cli/coordination/store.mjs +18 -0
- package/src/cli/coverage-check/user-outcomes.mjs +52 -17
- package/src/cli/dispatch.mjs +29 -0
- package/src/cli/gate/gates/verify-approved.mjs +22 -81
- package/src/cli/git/default-branch.mjs +250 -0
- package/src/cli/git/identity.mjs +9 -3
- package/src/cli/install/legacy-paths.mjs +2 -0
- package/src/cli/install/mcp-server-specs.mjs +24 -4
- package/src/cli/roadmap/backend-adapter.mjs +231 -0
- package/src/cli/util/runtime-capabilities.mjs +67 -0
- package/src/cli/verify-proof/manifest-validator.mjs +15 -0
- package/src/cli/verify-proof/resolver.mjs +27 -7
- package/src/cli/verify-proof/sandbox/apply.mjs +401 -0
- package/src/cli/verify-proof/sandbox/gate-predicate.mjs +126 -0
- package/src/cli/verify-proof/sandbox/pattern-matcher.mjs +127 -0
- package/src/cli/verify-proof/sandbox/recipe-diff.mjs +208 -0
- package/src/cli/verify-proof/sandbox/sandbox-config.mjs +82 -0
- package/src/cli/verify-proof/sandbox/sandbox-schema.json +33 -0
- package/src/cli/verify-proof/sandbox/self-test.mjs +265 -0
- package/templates/config.json +21 -1
- package/templates/proof-verification.md +27 -8
- package/workflows/verify-proof.md +376 -302
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply.mjs — recipe applier with dry-run default + per-change
|
|
3
|
+
* confirmation + TOFU non-clobber + audit-row writing.
|
|
4
|
+
*
|
|
5
|
+
* Three layered concerns covered here (subtasks 3.1, 3.2, 3.3):
|
|
6
|
+
*
|
|
7
|
+
* 3.1 — `confirm:false` (the default) is a pure dry-run. Returns
|
|
8
|
+
* the planned writes list; writes ZERO bytes under the
|
|
9
|
+
* adopter's source tree (ID-02).
|
|
10
|
+
*
|
|
11
|
+
* 3.2 — TOFU non-clobber. Per-change skip semantics modeled on
|
|
12
|
+
* install/mcp-provision.mjs's already_applied / user_modified
|
|
13
|
+
* / malformed_json / non_object_root states:
|
|
14
|
+
* - already_applied: target file contains the recipe's marker
|
|
15
|
+
* substring → no write.
|
|
16
|
+
* - user_modified: target file exists, doesn't contain the
|
|
17
|
+
* marker, AND its content sha256 doesn't match the
|
|
18
|
+
* recipe's expected-before hash (when provided) → no write.
|
|
19
|
+
* - malformed_json / non_object_root: applicable to
|
|
20
|
+
* json-merge changes only.
|
|
21
|
+
*
|
|
22
|
+
* 3.3 — Audit-row writing under withLock to .aperant/recipes-applied/
|
|
23
|
+
* {recipe-id}.json. Captures original_sha256 + applied_sha256
|
|
24
|
+
* + recipe_version + applied_at per change. file-create rows
|
|
25
|
+
* record original_sha256:null.
|
|
26
|
+
*
|
|
27
|
+
* Public contract:
|
|
28
|
+
* applyRecipe({projectDir, recipeId, recipes, confirm, perChangeConfirm})
|
|
29
|
+
* → {writes, skips, audit_path}
|
|
30
|
+
*
|
|
31
|
+
* No process.exit; no thrown errors except for usage-shape violations.
|
|
32
|
+
* The CLI command wraps this and translates to the {envelope, exitCode}
|
|
33
|
+
* shape.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash } from 'node:crypto'
|
|
37
|
+
import { chmodSync, existsSync, readFileSync } from 'node:fs'
|
|
38
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
39
|
+
import { fileURLToPath } from 'node:url'
|
|
40
|
+
import { atomicWriteJson, atomicWriteText } from '../../util/atomic-write.mjs'
|
|
41
|
+
import { withFileLock } from '../../util/fs-lock.mjs'
|
|
42
|
+
|
|
43
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Defense-in-depth: assert a recipe-declared path resolves under the
|
|
47
|
+
* expected root. Rejects absolute paths and `../` escapes regardless of
|
|
48
|
+
* what the recipe schema validator accepted.
|
|
49
|
+
*
|
|
50
|
+
* Throws — caller surfaces the message in audit + CLI envelope. We throw
|
|
51
|
+
* rather than skip because a malicious-path-in-recipe is a CONTRACT
|
|
52
|
+
* violation, not a TOFU skip case.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} rootAbs — absolute root the path must stay under.
|
|
55
|
+
* @param {string} relPath — recipe-declared relative path.
|
|
56
|
+
* @param {string} label — 'change.path' | 'change.template' for the error message.
|
|
57
|
+
* @returns {string} the validated absolute path under rootAbs.
|
|
58
|
+
*/
|
|
59
|
+
function assertPathUnderRoot(rootAbs, relPath, label) {
|
|
60
|
+
if (typeof relPath !== 'string' || relPath.length === 0) {
|
|
61
|
+
throw new Error(`applyRecipe: ${label} must be a non-empty string`)
|
|
62
|
+
}
|
|
63
|
+
if (isAbsolute(relPath)) {
|
|
64
|
+
throw new Error(`applyRecipe: ${label} must be relative, got absolute: ${relPath}`)
|
|
65
|
+
}
|
|
66
|
+
const candidate = resolve(rootAbs, relPath)
|
|
67
|
+
const rel = relative(rootAbs, candidate)
|
|
68
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
69
|
+
throw new Error(`applyRecipe: ${label} escapes root: ${relPath}`)
|
|
70
|
+
}
|
|
71
|
+
return candidate
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} ApplyChangeRecord
|
|
76
|
+
* @property {string} path — adopter-relative target
|
|
77
|
+
* @property {'written'|'skipped'|'skipped_unsafe'} action
|
|
78
|
+
* @property {string} [skip_reason] — already_applied | user_modified | malformed_json | non_object_root | dry_run
|
|
79
|
+
* @property {string|null} original_sha256
|
|
80
|
+
* @property {string|null} applied_sha256
|
|
81
|
+
* @property {string} recipe_version
|
|
82
|
+
* @property {string} applied_at
|
|
83
|
+
* @property {string} kind
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {{
|
|
88
|
+
* projectDir: string,
|
|
89
|
+
* recipeId: string,
|
|
90
|
+
* recipes: object[],
|
|
91
|
+
* confirm?: boolean,
|
|
92
|
+
* perChangeConfirm?: (change: object, preview: {before: string, after: string}) => Promise<'accept'|'skip'|'abort'>,
|
|
93
|
+
* force?: boolean
|
|
94
|
+
* }} args
|
|
95
|
+
* @returns {Promise<{
|
|
96
|
+
* writes: ApplyChangeRecord[],
|
|
97
|
+
* skips: ApplyChangeRecord[],
|
|
98
|
+
* audit_path: string|null,
|
|
99
|
+
* aborted: boolean,
|
|
100
|
+
* recipe_id: string,
|
|
101
|
+
* recipe_version: string
|
|
102
|
+
* }>}
|
|
103
|
+
*/
|
|
104
|
+
export async function applyRecipe(args) {
|
|
105
|
+
const { projectDir, recipeId, recipes, confirm = false, perChangeConfirm, force = false } = args
|
|
106
|
+
if (!projectDir) throw new Error('applyRecipe: projectDir required')
|
|
107
|
+
if (!recipeId) throw new Error('applyRecipe: recipeId required')
|
|
108
|
+
if (!Array.isArray(recipes)) throw new Error('applyRecipe: recipes[] required')
|
|
109
|
+
|
|
110
|
+
const recipe = recipes.find((r) => r && r.recipeId === recipeId)
|
|
111
|
+
if (!recipe) throw new Error(`applyRecipe: recipe not found: ${recipeId}`)
|
|
112
|
+
|
|
113
|
+
const adopterRoot = resolve(projectDir)
|
|
114
|
+
const writes = []
|
|
115
|
+
const skips = []
|
|
116
|
+
let aborted = false
|
|
117
|
+
const at = new Date().toISOString()
|
|
118
|
+
|
|
119
|
+
for (const change of recipe.changes ?? []) {
|
|
120
|
+
// Defense-in-depth: reject path-traversal in recipe-declared paths
|
|
121
|
+
// regardless of schema validation. A malicious community recipe
|
|
122
|
+
// could otherwise write outside the adopter root via `../../`.
|
|
123
|
+
const targetAbs = assertPathUnderRoot(adopterRoot, change.path, 'change.path')
|
|
124
|
+
const exists = existsSync(targetAbs)
|
|
125
|
+
const before = exists ? safeReadFile(targetAbs) : ''
|
|
126
|
+
const beforeSha = exists ? sha256(before) : null
|
|
127
|
+
const afterContent = renderAfterContent(change, recipe)
|
|
128
|
+
|
|
129
|
+
// TOFU non-clobber checks — per-change skip decisions.
|
|
130
|
+
const tofu = decideTofu({ change, before, exists, force })
|
|
131
|
+
if (tofu.skip) {
|
|
132
|
+
skips.push({
|
|
133
|
+
path: change.path,
|
|
134
|
+
kind: change.kind,
|
|
135
|
+
action: tofu.unsafe ? 'skipped_unsafe' : 'skipped',
|
|
136
|
+
skip_reason: tofu.reason,
|
|
137
|
+
original_sha256: beforeSha,
|
|
138
|
+
applied_sha256: null,
|
|
139
|
+
recipe_version: recipe.recipeVersion,
|
|
140
|
+
applied_at: at,
|
|
141
|
+
})
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const preview = { before, after: afterContent }
|
|
146
|
+
|
|
147
|
+
if (!confirm) {
|
|
148
|
+
// Dry-run: no write, no per-change prompt. The plan is the output.
|
|
149
|
+
writes.push({
|
|
150
|
+
path: change.path,
|
|
151
|
+
kind: change.kind,
|
|
152
|
+
action: 'written',
|
|
153
|
+
skip_reason: 'dry_run',
|
|
154
|
+
original_sha256: beforeSha,
|
|
155
|
+
applied_sha256: null,
|
|
156
|
+
recipe_version: recipe.recipeVersion,
|
|
157
|
+
applied_at: at,
|
|
158
|
+
})
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// With --confirm: ask the user per change. Default WHEN NO
|
|
163
|
+
// confirmer is provided is `skip` (fail-safe per ID-02 — the
|
|
164
|
+
// framework never auto-applies without an explicit accept signal
|
|
165
|
+
// from the caller surface).
|
|
166
|
+
const verdict = perChangeConfirm ? await perChangeConfirm(change, preview) : 'skip'
|
|
167
|
+
if (verdict === 'abort') {
|
|
168
|
+
aborted = true
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
if (verdict === 'skip') {
|
|
172
|
+
skips.push({
|
|
173
|
+
path: change.path,
|
|
174
|
+
kind: change.kind,
|
|
175
|
+
action: 'skipped',
|
|
176
|
+
skip_reason: 'user_declined',
|
|
177
|
+
original_sha256: beforeSha,
|
|
178
|
+
applied_sha256: null,
|
|
179
|
+
recipe_version: recipe.recipeVersion,
|
|
180
|
+
applied_at: at,
|
|
181
|
+
})
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Verdict 'accept' — perform the write.
|
|
186
|
+
const writeResult = performWrite(targetAbs, change, afterContent, before)
|
|
187
|
+
writes.push({
|
|
188
|
+
path: change.path,
|
|
189
|
+
kind: change.kind,
|
|
190
|
+
action: 'written',
|
|
191
|
+
skip_reason: null,
|
|
192
|
+
original_sha256: beforeSha,
|
|
193
|
+
applied_sha256: writeResult.applied_sha256,
|
|
194
|
+
recipe_version: recipe.recipeVersion,
|
|
195
|
+
applied_at: at,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Audit row write — only when we actually wrote (or attempted to).
|
|
200
|
+
// For dry-runs we still return the plan in `writes` but do NOT touch
|
|
201
|
+
// the audit file. ID-02: dry-run never mutates state.
|
|
202
|
+
let auditPath = null
|
|
203
|
+
if (confirm) {
|
|
204
|
+
auditPath = join(adopterRoot, '.aperant', 'recipes-applied', `${recipe.recipeId}.json`)
|
|
205
|
+
writeAuditRow({
|
|
206
|
+
auditPath,
|
|
207
|
+
recipeId: recipe.recipeId,
|
|
208
|
+
recipeVersion: recipe.recipeVersion,
|
|
209
|
+
at,
|
|
210
|
+
writes,
|
|
211
|
+
skips,
|
|
212
|
+
aborted,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
writes,
|
|
218
|
+
skips,
|
|
219
|
+
audit_path: auditPath,
|
|
220
|
+
aborted,
|
|
221
|
+
recipe_id: recipe.recipeId,
|
|
222
|
+
recipe_version: recipe.recipeVersion,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* TOFU decision per change. Returns {skip:true, reason, unsafe?} or
|
|
228
|
+
* {skip:false}.
|
|
229
|
+
*/
|
|
230
|
+
function decideTofu({ change, before, exists, force }) {
|
|
231
|
+
if (!exists) {
|
|
232
|
+
// New target — always safe to write.
|
|
233
|
+
return { skip: false }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Marker check first — if the recipe's marker substring is already
|
|
237
|
+
// in the file, treat as idempotent application (regardless of kind).
|
|
238
|
+
if (
|
|
239
|
+
typeof change.marker === 'string' &&
|
|
240
|
+
change.marker.length > 0 &&
|
|
241
|
+
before.includes(change.marker)
|
|
242
|
+
) {
|
|
243
|
+
return { skip: true, reason: 'already_applied' }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// json-merge specific safety: only touch plain-object roots.
|
|
247
|
+
if (change.kind === 'json-merge') {
|
|
248
|
+
let parsed
|
|
249
|
+
try {
|
|
250
|
+
parsed = before === '' ? {} : JSON.parse(before)
|
|
251
|
+
} catch {
|
|
252
|
+
return { skip: true, reason: 'malformed_json', unsafe: true }
|
|
253
|
+
}
|
|
254
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
255
|
+
return { skip: true, reason: 'non_object_root', unsafe: true }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// `before` SHA divergence: target file exists but the recipe has an
|
|
260
|
+
// expected before-content/sha and it doesn't match. Without --force,
|
|
261
|
+
// skip as user_modified. A 64-char hex string is treated as a SHA;
|
|
262
|
+
// anything else is treated as a substring anchor.
|
|
263
|
+
if (!force && typeof change.before === 'string' && change.before.length > 0) {
|
|
264
|
+
if (/^[a-f0-9]{64}$/.test(change.before)) {
|
|
265
|
+
if (sha256(before) !== change.before) {
|
|
266
|
+
return { skip: true, reason: 'user_modified' }
|
|
267
|
+
}
|
|
268
|
+
} else if (!before.includes(change.before)) {
|
|
269
|
+
return { skip: true, reason: 'user_modified' }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { skip: false }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Materialize the post-condition content for a change.
|
|
278
|
+
*/
|
|
279
|
+
function renderAfterContent(change, recipe) {
|
|
280
|
+
if (change.kind === 'json-merge') {
|
|
281
|
+
return JSON.stringify(change.after ?? {}, null, 2)
|
|
282
|
+
}
|
|
283
|
+
if (change.template) {
|
|
284
|
+
const tpl = locateRecipeTemplate(recipe, change.template)
|
|
285
|
+
if (tpl && existsSync(tpl)) return safeReadFile(tpl)
|
|
286
|
+
}
|
|
287
|
+
if (typeof change.after === 'string') return change.after
|
|
288
|
+
return ''
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function performWrite(targetAbs, change, afterContent, before) {
|
|
292
|
+
if (change.kind === 'json-merge') {
|
|
293
|
+
// Deep-merge change.after into existing JSON (already verified to
|
|
294
|
+
// be plain-object root in decideTofu).
|
|
295
|
+
const parsed = before === '' ? {} : JSON.parse(before)
|
|
296
|
+
const merged = deepMerge(parsed, change.after ?? {})
|
|
297
|
+
atomicWriteJson(targetAbs, merged)
|
|
298
|
+
const out = `${JSON.stringify(merged, null, 2)}\n`
|
|
299
|
+
return { applied_sha256: sha256(out) }
|
|
300
|
+
}
|
|
301
|
+
if (change.kind === 'text-insert') {
|
|
302
|
+
// v1: append the patch block after the existing file content. Adopters
|
|
303
|
+
// confirm per-change; the diff has been previewed; explicit append is
|
|
304
|
+
// the safest non-destructive semantic for a v1.
|
|
305
|
+
const out = before + (before.endsWith('\n') ? '' : '\n') + afterContent
|
|
306
|
+
atomicWriteText(targetAbs, out)
|
|
307
|
+
return { applied_sha256: sha256(out) }
|
|
308
|
+
}
|
|
309
|
+
if (change.kind === 'file-create' || change.kind === 'helper-script') {
|
|
310
|
+
atomicWriteText(targetAbs, afterContent)
|
|
311
|
+
if (typeof change.mode === 'number') {
|
|
312
|
+
try {
|
|
313
|
+
chmodSync(targetAbs, change.mode)
|
|
314
|
+
} catch {
|
|
315
|
+
// chmod failure is non-fatal — the recipe's verify-sandbox step
|
|
316
|
+
// will surface a non-executable script as a self-test failure.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { applied_sha256: sha256(afterContent) }
|
|
320
|
+
}
|
|
321
|
+
// Unknown kind — fail loud (ID-01). A recipe shipping an unrecognized
|
|
322
|
+
// change.kind is a contract violation, not a TOFU skip case.
|
|
323
|
+
throw Object.assign(
|
|
324
|
+
new Error(`applyRecipe: unknown change.kind '${change.kind}' on path '${change.path}'`),
|
|
325
|
+
{
|
|
326
|
+
code: 'E_UNKNOWN_CHANGE_KIND',
|
|
327
|
+
kind: change.kind,
|
|
328
|
+
path: change.path,
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function deepMerge(a, b) {
|
|
334
|
+
if (b === null || typeof b !== 'object' || Array.isArray(b)) return b
|
|
335
|
+
if (a === null || typeof a !== 'object' || Array.isArray(a)) return b
|
|
336
|
+
const out = { ...a }
|
|
337
|
+
for (const k of Object.keys(b)) {
|
|
338
|
+
out[k] = k in a ? deepMerge(a[k], b[k]) : b[k]
|
|
339
|
+
}
|
|
340
|
+
return out
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Write/update the audit row file under withFileLock. The lock helper is
|
|
345
|
+
* synchronous (it uses proper-lockfile's sync API + an Atomics.wait
|
|
346
|
+
* retry loop), so this function is sync — the caller already declared
|
|
347
|
+
* `await` for forward-compatibility with a future async lock.
|
|
348
|
+
*/
|
|
349
|
+
const AUDIT_ENTRIES_CAP = 50
|
|
350
|
+
|
|
351
|
+
function writeAuditRow({ auditPath, recipeId, recipeVersion, at, writes, skips, aborted }) {
|
|
352
|
+
withFileLock(auditPath, () => {
|
|
353
|
+
const existing = existsSync(auditPath) ? JSON.parse(safeReadFile(auditPath) || '{}') : {}
|
|
354
|
+
const entries = Array.isArray(existing.entries) ? existing.entries : []
|
|
355
|
+
entries.push({
|
|
356
|
+
recipe_id: recipeId,
|
|
357
|
+
recipe_version: recipeVersion,
|
|
358
|
+
applied_at: at,
|
|
359
|
+
aborted,
|
|
360
|
+
writes,
|
|
361
|
+
skips,
|
|
362
|
+
})
|
|
363
|
+
// Cap to the last N entries to prevent unbounded disk growth (PRF-003).
|
|
364
|
+
const capped = entries.length > AUDIT_ENTRIES_CAP ? entries.slice(-AUDIT_ENTRIES_CAP) : entries
|
|
365
|
+
const doc = {
|
|
366
|
+
recipe_id: recipeId,
|
|
367
|
+
latest_version: recipeVersion,
|
|
368
|
+
last_applied_at: at,
|
|
369
|
+
entries: capped,
|
|
370
|
+
}
|
|
371
|
+
atomicWriteJson(auditPath, doc)
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function safeReadFile(path) {
|
|
376
|
+
try {
|
|
377
|
+
return readFileSync(path, 'utf-8')
|
|
378
|
+
} catch {
|
|
379
|
+
return ''
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sha256(content) {
|
|
384
|
+
return createHash('sha256').update(content, 'utf-8').digest('hex')
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function locateRecipeTemplate(recipe, templateName) {
|
|
388
|
+
// Defense-in-depth: a malicious recipe could otherwise specify
|
|
389
|
+
// `templateName: "../../etc/passwd"` and read arbitrary host files
|
|
390
|
+
// into the adopter source tree. Resolve under templates/ and assert.
|
|
391
|
+
// Also guard the recipeId itself — discoverBundledRecipes() skips schema
|
|
392
|
+
// validation, so the ^[a-z][a-z0-9_-]*$ pattern may not have been enforced.
|
|
393
|
+
if (typeof recipe.recipeId !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(recipe.recipeId)) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`applyRecipe: invalid recipeId '${recipe.recipeId}' — must match ^[a-z][a-z0-9_-]*$`,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
const frameworkRoot = resolve(__dirname, '..', '..', '..', '..')
|
|
399
|
+
const templatesRoot = join(frameworkRoot, 'recipes', recipe.recipeId, 'templates')
|
|
400
|
+
return assertPathUnderRoot(templatesRoot, templateName, 'change.template')
|
|
401
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-predicate.mjs — extracted decision function for the sandbox
|
|
3
|
+
* precondition gate at apt-verify-proof/SKILL.md Step 2c.
|
|
4
|
+
*
|
|
5
|
+
* Refactored out of the SKILL.md prose so the e2e test (subtask 7.3)
|
|
6
|
+
* can call it directly without re-implementing the dispatch table.
|
|
7
|
+
*
|
|
8
|
+
* Inputs:
|
|
9
|
+
* - config: {verification?: {sandbox?: {enabled:bool, ...}}}
|
|
10
|
+
* - stateFile: the v2-shaped envelope read from
|
|
11
|
+
* .aperant/state/verify-proof-sandbox-verified.json
|
|
12
|
+
* OR null when the file is missing.
|
|
13
|
+
* - parallelRequested: bool — true when /apt:verify-proof was invoked
|
|
14
|
+
* with --parallel.
|
|
15
|
+
*
|
|
16
|
+
* Returns:
|
|
17
|
+
* - {action: 'proceed'} — verified + approved, walker may run
|
|
18
|
+
* - {action: 'skip-gate'} — --parallel NOT requested OR
|
|
19
|
+
* verification.sandbox absent;
|
|
20
|
+
* Fast Path Guarantee (ID-07).
|
|
21
|
+
* - {action: 'exit', envelope} — verified:false OR state missing;
|
|
22
|
+
* envelope is the v2 fail-loud shape.
|
|
23
|
+
*
|
|
24
|
+
* The `envelope` shape on exit is:
|
|
25
|
+
* {status:'error', code:'E_SANDBOX_UNVERIFIED', verdict:'needs_human',
|
|
26
|
+
* reason:'sandbox_unverified', remediation:'/apt:verify-proof setup'}
|
|
27
|
+
*
|
|
28
|
+
* No I/O, no process.exit — pure function for testability.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const FAIL_ENVELOPE = Object.freeze({
|
|
32
|
+
status: 'error',
|
|
33
|
+
code: 'E_SANDBOX_UNVERIFIED',
|
|
34
|
+
verdict: 'needs_human',
|
|
35
|
+
reason: 'sandbox_unverified',
|
|
36
|
+
remediation: '/apt:verify-proof setup',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const STALE_VERDICT_ENVELOPE = Object.freeze({
|
|
40
|
+
status: 'error',
|
|
41
|
+
code: 'E_SANDBOX_VERDICT_STALE',
|
|
42
|
+
verdict: 'needs_human',
|
|
43
|
+
reason: 'sandbox_recipe_version_drift',
|
|
44
|
+
remediation: 'apt-tools verify-sandbox <project-dir>',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} GateInput
|
|
49
|
+
* @property {object|null|undefined} config
|
|
50
|
+
* @property {object|null|undefined} stateFile
|
|
51
|
+
* @property {boolean} parallelRequested
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} GateEnvelope
|
|
56
|
+
* @property {'error'} status
|
|
57
|
+
* @property {string} code
|
|
58
|
+
* @property {string} verdict
|
|
59
|
+
* @property {string} reason
|
|
60
|
+
* @property {string} remediation
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} GateOutput
|
|
65
|
+
* @property {'proceed'|'skip-gate'|'exit'} action
|
|
66
|
+
* @property {GateEnvelope} [envelope]
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {GateInput} input
|
|
71
|
+
* @returns {GateOutput}
|
|
72
|
+
*/
|
|
73
|
+
export function gatePredicate({ config, stateFile, parallelRequested }) {
|
|
74
|
+
// (1) --parallel NOT requested → gate is a no-op (Fast Path Guarantee ID-07).
|
|
75
|
+
if (!parallelRequested) return { action: 'skip-gate' }
|
|
76
|
+
|
|
77
|
+
// (2) verification.sandbox is ABSENT → also skip-gate (the adopter
|
|
78
|
+
// has not opted in; respect the absent-config branch as no-op).
|
|
79
|
+
const sandboxCfg = config?.verification?.sandbox
|
|
80
|
+
if (!sandboxCfg || sandboxCfg.enabled !== true) {
|
|
81
|
+
// Note: even though --parallel was requested, the absent-config
|
|
82
|
+
// branch returns skip-gate; the SKILL.md Step 2c prose handles
|
|
83
|
+
// the absent-config case via the first-run hook (Step 1) which
|
|
84
|
+
// is a UX surface, not a gate failure.
|
|
85
|
+
return { action: 'skip-gate' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// (3) state file present + verified:true + verdict:'approved' → check version drift.
|
|
89
|
+
if (stateFile && stateFile.verified === true && stateFile.verdict === 'approved') {
|
|
90
|
+
// (3a) Version drift check: when both sides have recipeVersion, verify the
|
|
91
|
+
// state file's version is in the active recipe's compatibleWithApplied[].
|
|
92
|
+
// If either side has null/undefined recipeVersion, fall through (compat
|
|
93
|
+
// with pre-LOG-003 state files that predate recipeVersion population).
|
|
94
|
+
const stateVersion = stateFile.recipeVersion ?? null
|
|
95
|
+
const activeVersion = sandboxCfg.recipeVersion ?? null
|
|
96
|
+
const compatibleWith = Array.isArray(sandboxCfg.compatibleWithApplied)
|
|
97
|
+
? sandboxCfg.compatibleWithApplied
|
|
98
|
+
: null
|
|
99
|
+
|
|
100
|
+
if (stateVersion && activeVersion && compatibleWith !== null) {
|
|
101
|
+
// Both sides versioned — only stale if the state version is NOT
|
|
102
|
+
// listed in the active recipe's compatibleWithApplied AND the
|
|
103
|
+
// versions differ.
|
|
104
|
+
if (stateVersion !== activeVersion && !compatibleWith.includes(stateVersion)) {
|
|
105
|
+
return { action: 'exit', envelope: { ...STALE_VERDICT_ENVELOPE } }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { action: 'proceed' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// (4) Everything else → fail loud with the v2 envelope.
|
|
113
|
+
return { action: 'exit', envelope: { ...FAIL_ENVELOPE } }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The frozen fail envelope — exported so callers can detect-and-match
|
|
118
|
+
* without re-stringifying.
|
|
119
|
+
*/
|
|
120
|
+
export const SANDBOX_UNVERIFIED_ENVELOPE = FAIL_ENVELOPE
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The frozen stale-verdict envelope — emitted when the state file's
|
|
124
|
+
* recipeVersion is no longer in the active recipe's compatibleWithApplied[].
|
|
125
|
+
*/
|
|
126
|
+
export const SANDBOX_VERDICT_STALE_ENVELOPE = STALE_VERDICT_ENVELOPE
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pattern-matcher.mjs — sandbox-recipe pattern matcher.
|
|
3
|
+
*
|
|
4
|
+
* Walks the registered recipes' `match.capabilities[]` + `match.deps[]`
|
|
5
|
+
* predicates against the loaded runtime capabilities + package.json deps,
|
|
6
|
+
* and returns the FIRST matching recipe (or null with a human reason).
|
|
7
|
+
*
|
|
8
|
+
* Two-arg shape:
|
|
9
|
+
* - `match.capabilities[]` — every entry must be `true` on the
|
|
10
|
+
* RuntimeCapabilities record (AND semantics — all required).
|
|
11
|
+
* - `match.deps[]` — at least one entry must appear in the merged
|
|
12
|
+
* dependencies + devDependencies of the project's root package.json
|
|
13
|
+
* (OR semantics — any-of).
|
|
14
|
+
*
|
|
15
|
+
* This split lets a recipe say "I need is_electron AND one of
|
|
16
|
+
* [@libsql/client, better-sqlite3]" without enumerating every combination.
|
|
17
|
+
*
|
|
18
|
+
* See: spec ID-04 (v1 ships ONE recipe — electron-libsql), AC5.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {import('../../util/runtime-capabilities.mjs').RuntimeCapabilities} RuntimeCapabilities
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} SandboxRecipe
|
|
30
|
+
* @property {string} recipeId
|
|
31
|
+
* @property {string} recipeVersion
|
|
32
|
+
* @property {string} name
|
|
33
|
+
* @property {string} description
|
|
34
|
+
* @property {{capabilities: string[], deps: string[]}} match
|
|
35
|
+
* @property {{logPattern: string, timeoutMs: number}} readiness
|
|
36
|
+
* @property {Array<object>} changes
|
|
37
|
+
* @property {string[]} compatibleWithApplied
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} MatchResult
|
|
42
|
+
* @property {string|null} recipeId
|
|
43
|
+
* @property {string} reason
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {RuntimeCapabilities} runtimeCaps
|
|
48
|
+
* @param {Record<string, unknown> | null} packageJson
|
|
49
|
+
* @param {SandboxRecipe[]} recipes
|
|
50
|
+
* @returns {MatchResult}
|
|
51
|
+
*/
|
|
52
|
+
export function matchPattern(runtimeCaps, packageJson, recipes) {
|
|
53
|
+
if (!Array.isArray(recipes) || recipes.length === 0) {
|
|
54
|
+
return { recipeId: null, reason: 'no recipes registered' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const deps = mergedDeps(packageJson)
|
|
58
|
+
|
|
59
|
+
for (const recipe of recipes) {
|
|
60
|
+
const reqCaps = recipe.match?.capabilities ?? []
|
|
61
|
+
const anyDeps = recipe.match?.deps ?? []
|
|
62
|
+
|
|
63
|
+
const capsOk = reqCaps.every((cap) => runtimeCaps && runtimeCaps[cap] === true)
|
|
64
|
+
if (!capsOk) continue
|
|
65
|
+
|
|
66
|
+
// deps[] is any-of — recipe matches if at least one declared dep is in
|
|
67
|
+
// the project. An empty deps[] means "no dep constraint" (cap-only match).
|
|
68
|
+
const depsOk = anyDeps.length === 0 || anyDeps.some((d) => deps.has(d.toLowerCase()))
|
|
69
|
+
if (!depsOk) continue
|
|
70
|
+
|
|
71
|
+
return { recipeId: recipe.recipeId, reason: 'matched' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
recipeId: null,
|
|
76
|
+
reason: `no recipe available for runtime: ${describeRuntime(runtimeCaps)}`,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convenience overload — load the project's package.json from disk.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} projectDir
|
|
84
|
+
* @param {RuntimeCapabilities} runtimeCaps
|
|
85
|
+
* @param {SandboxRecipe[]} recipes
|
|
86
|
+
* @returns {MatchResult}
|
|
87
|
+
*/
|
|
88
|
+
export function matchPatternForProject(projectDir, runtimeCaps, recipes) {
|
|
89
|
+
const pkgPath = join(resolve(projectDir), 'package.json')
|
|
90
|
+
let pkg = null
|
|
91
|
+
if (existsSync(pkgPath)) {
|
|
92
|
+
try {
|
|
93
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
94
|
+
} catch {
|
|
95
|
+
/* fall through with pkg=null */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return matchPattern(runtimeCaps, pkg, recipes)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build a Set of lowercased package names from dependencies + devDependencies.
|
|
103
|
+
*
|
|
104
|
+
* @param {Record<string, unknown> | null} packageJson
|
|
105
|
+
*/
|
|
106
|
+
function mergedDeps(packageJson) {
|
|
107
|
+
const out = new Set()
|
|
108
|
+
if (packageJson === null || typeof packageJson !== 'object') return out
|
|
109
|
+
const d = /** @type {Record<string, unknown>} */ (packageJson.dependencies)
|
|
110
|
+
const dd = /** @type {Record<string, unknown>} */ (packageJson.devDependencies)
|
|
111
|
+
if (d && typeof d === 'object') for (const k of Object.keys(d)) out.add(k.toLowerCase())
|
|
112
|
+
if (dd && typeof dd === 'object') for (const k of Object.keys(dd)) out.add(k.toLowerCase())
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One-line description of the detected runtime shape — used in the
|
|
118
|
+
* no-match reason so an adopter sees what the matcher saw.
|
|
119
|
+
*
|
|
120
|
+
* @param {RuntimeCapabilities} runtimeCaps
|
|
121
|
+
*/
|
|
122
|
+
function describeRuntime(runtimeCaps) {
|
|
123
|
+
if (!runtimeCaps || typeof runtimeCaps !== 'object') return 'unknown'
|
|
124
|
+
const active = Object.keys(runtimeCaps).filter((k) => runtimeCaps[k] === true)
|
|
125
|
+
if (active.length === 0) return 'no capabilities detected'
|
|
126
|
+
return active.join(', ')
|
|
127
|
+
}
|