@cassiomc1/forgeloop 0.1.8 → 0.1.10
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/LOOP_ENGINEERING.md +5 -1
- package/README.md +73 -3
- package/THREAT_MODEL.md +6 -1
- package/conformance/README.md +67 -0
- package/package.json +3 -2
- package/src/commands/doctor.js +38 -6
- package/src/commands/update.js +144 -38
- package/src/core/checks.js +19 -8
- package/src/core/completion-relationships.js +2 -2
- package/src/core/manifest.js +4 -0
package/LOOP_ENGINEERING.md
CHANGED
|
@@ -323,7 +323,11 @@ observed in the target. New targets receive canonical documents under
|
|
|
323
323
|
`.forgeloop/kit/`; root `AGENTS.md`, `CLAUDE.md`, Cursor, and Copilot files are
|
|
324
324
|
minimal native shims. `forgeloop update` migrates unchanged managed legacy root
|
|
325
325
|
files, preserves modified or unowned files, and reports conflicts without
|
|
326
|
-
following symlinks or escaping the selected target.
|
|
326
|
+
following symlinks or escaping the selected target. Migration validates the
|
|
327
|
+
complete plan, writes and verifies hidden destinations, atomically switches the
|
|
328
|
+
manifest authority, and then cleans only legacy files whose recorded ownership
|
|
329
|
+
hash still matches. `doctor` reports `E_MIGRATION_INCOMPLETE` for an interrupted
|
|
330
|
+
authority switch or cleanup; a later `update` may safely resume that cleanup.
|
|
327
331
|
|
|
328
332
|
## Completion validation and chronology
|
|
329
333
|
|
package/README.md
CHANGED
|
@@ -59,6 +59,19 @@ flowchart TB
|
|
|
59
59
|
root["FORGELOOP"]
|
|
60
60
|
|
|
61
61
|
root --> entry["native adapters<br/>+ .forgeloop/kit/"]
|
|
62
|
+
entry --> migration{"target<br/>layout"}
|
|
63
|
+
migration -->|legacy v1| plan["validate paths<br/>+ build plan"]
|
|
64
|
+
plan --> hidden["write hidden<br/>kit"]
|
|
65
|
+
hidden --> verified["verify hidden<br/>bytes"]
|
|
66
|
+
verified --> authority["atomic manifest<br/>authority switch"]
|
|
67
|
+
authority --> cleanup["hash-checked<br/>legacy cleanup"]
|
|
68
|
+
cleanup --> recovered["healthy or<br/>recoverable"]
|
|
69
|
+
hidden -. interruption .-> incomplete["doctor: E_MIGRATION_INCOMPLETE"]
|
|
70
|
+
verified -. interruption .-> incomplete
|
|
71
|
+
authority -. interruption .-> incomplete
|
|
72
|
+
cleanup -. interruption .-> incomplete
|
|
73
|
+
migration -->|layout v2| retry["update + retry<br/>owned cleanup"]
|
|
74
|
+
incomplete --> retry
|
|
62
75
|
entry --> discovery["discovery +<br/>project profile"]
|
|
63
76
|
discovery --> contract["current<br/>contract"]
|
|
64
77
|
contract --> routing["deterministic<br/>route"]
|
|
@@ -97,6 +110,7 @@ flowchart TB
|
|
|
97
110
|
|
|
98
111
|
classDef root fill:#08090C,stroke:#6E6AF5,stroke-width:3px,color:#EDEEF0;
|
|
99
112
|
classDef entry fill:#101218,stroke:#3EDBB8,stroke-width:2px,color:#EDEEF0;
|
|
113
|
+
classDef migration fill:#0F766E,stroke:#5EEAD4,stroke-width:2px,color:#FFFFFF;
|
|
100
114
|
classDef routing fill:#4F46E5,stroke:#A5B4FC,stroke-width:2px,color:#FFFFFF;
|
|
101
115
|
classDef state fill:#373A46,stroke:#A1A1AA,stroke-width:2px,color:#FFFFFF;
|
|
102
116
|
classDef evidence fill:#3EDBB8,stroke:#99F6E4,stroke-width:2px,color:#08090C;
|
|
@@ -107,6 +121,7 @@ flowchart TB
|
|
|
107
121
|
|
|
108
122
|
class root root;
|
|
109
123
|
class entry,discovery,delegation entry;
|
|
124
|
+
class migration,plan,hidden,verified,authority,cleanup,recovered,retry,incomplete migration;
|
|
110
125
|
class routing,preflight,ready routing;
|
|
111
126
|
class state,events,lifecycle state;
|
|
112
127
|
class checks,receipt,audit evidence;
|
|
@@ -118,6 +133,11 @@ flowchart TB
|
|
|
118
133
|
```
|
|
119
134
|
|
|
120
135
|
Equivalent reading for text-only environments: adapters load the canonical kit;
|
|
136
|
+
an older target follows validate paths → write hidden files → verify their bytes
|
|
137
|
+
→ switch manifest authority atomically → hash-checked cleanup. An interruption
|
|
138
|
+
after hidden writes, after verification, after the authority switch, or during
|
|
139
|
+
cleanup is diagnosed by `doctor` as `E_MIGRATION_INCOMPLETE` and retried by
|
|
140
|
+
`update`; modified or unmanaged residual files remain preserved;
|
|
121
141
|
discovery creates the contract and deterministic route; contract, route, and
|
|
122
142
|
required gates must produce `PREFLIGHT_READY` before the resumable state and
|
|
123
143
|
append-only event ledger authorize the lifecycle. Verification produces
|
|
@@ -138,6 +158,37 @@ Antigravity, OpenCode, Hermes, Pi, Command Code, and Freebuff use the shared
|
|
|
138
158
|
documents; see [`AGENT_COMPATIBILITY.md`](./AGENT_COMPATIBILITY.md) for the
|
|
139
159
|
official sources and precedence notes.
|
|
140
160
|
|
|
161
|
+
### Migration recovery and release freeze
|
|
162
|
+
|
|
163
|
+
Legacy layout migration keeps `.forgeloop/kit/` as the canonical layout and
|
|
164
|
+
does not make `layoutVersion: 2` authoritative until every planned hidden file
|
|
165
|
+
has been written and byte-verified. Cleanup runs only after the manifest switch
|
|
166
|
+
and only for legacy files whose recorded ownership hash still matches when
|
|
167
|
+
ForgeLoop revalidates it immediately before deletion. A modified, unmanaged,
|
|
168
|
+
or `preserve=true` file is retained for manual review.
|
|
169
|
+
|
|
170
|
+
The interruption vocabulary is test-only and is not a runtime state machine:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
VALIDATED → HIDDEN_WRITTEN → HIDDEN_VERIFIED → MANIFEST_SWITCHED
|
|
174
|
+
→ LEGACY_CLEANED → COMPLETE
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The regression suite injects failures at these boundaries and verifies that
|
|
178
|
+
`doctor` explains the incomplete migration before a later `update` recovers
|
|
179
|
+
owned cleanup. The frozen published installation under
|
|
180
|
+
[`tests/fixtures/legacy-0.1.6/`](./tests/fixtures/legacy-0.1.6/) is derived
|
|
181
|
+
from the real npm tarball, includes provenance and digests, and is copied into
|
|
182
|
+
tests locally; CI does not download npm packages.
|
|
183
|
+
|
|
184
|
+
The current published baseline for a reproducible blind run is
|
|
185
|
+
`@cassiomc1/forgeloop@0.1.9`. The supplied hardening brief's `0.1.8` reference
|
|
186
|
+
is historical; never move `v0.1.8` or `v0.1.9`, and use the read-only release
|
|
187
|
+
identity verifier before a live run. The repository candidate is `0.1.10`, but
|
|
188
|
+
it is not published: its completion-validation and cleanup TOCTOU fixes are not
|
|
189
|
+
part of the frozen `0.1.9` tarball. Publish, tag, and repeat identity
|
|
190
|
+
verification before a blind run that needs those executable changes.
|
|
191
|
+
|
|
141
192
|
### Use with npm
|
|
142
193
|
|
|
143
194
|
The npm CLI targets Node.js 20 or newer and installs the kit into an existing
|
|
@@ -145,7 +196,12 @@ project without overwriting local instructions. When the package is available
|
|
|
145
196
|
in the npm registry, use the commands below; otherwise use the repository
|
|
146
197
|
checkout fallback.
|
|
147
198
|
|
|
199
|
+
The current published release is `@cassiomc1/forgeloop@0.1.9`.
|
|
200
|
+
Pin this version when a reproducible blind run or release-identity check is
|
|
201
|
+
required:
|
|
202
|
+
|
|
148
203
|
```bash
|
|
204
|
+
npx @cassiomc1/forgeloop@0.1.9 --version
|
|
149
205
|
npx @cassiomc1/forgeloop init
|
|
150
206
|
npx @cassiomc1/forgeloop doctor
|
|
151
207
|
npx @cassiomc1/forgeloop update
|
|
@@ -327,8 +383,17 @@ node src/cli.js update
|
|
|
327
383
|
The release workflow uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers)
|
|
328
384
|
through GitHub Actions OIDC. Before the first release, register this repository
|
|
329
385
|
and workflow as the package's trusted publisher in npm; each `vX.Y.Z` tag must
|
|
330
|
-
match `package.json`. After publishing, verify the
|
|
331
|
-
|
|
386
|
+
match `package.json`. After publishing, verify the complete immutable release
|
|
387
|
+
identity before a blind run:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
RELEASE_COMMIT="$(git rev-list -n1 vX.Y.Z)"
|
|
391
|
+
npm run release:identity -- --version X.Y.Z --release-commit "$RELEASE_COMMIT"
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Only `RELEASE_IDENTITY_VALID` is sufficient. The read-only check compares the
|
|
395
|
+
release commit and GitHub tag with npm's version, `gitHead`, tarball URL,
|
|
396
|
+
SHA-1, and SHA-512 integrity; it never publishes or changes a tag.
|
|
332
397
|
|
|
333
398
|
The commands above use the current directory. To install into another existing
|
|
334
399
|
project directory, pass a relative or absolute `--path`:
|
|
@@ -360,7 +425,12 @@ reference.
|
|
|
360
425
|
Targets created by an older package layout are migrated by `update`: unchanged
|
|
361
426
|
managed root files move into the hidden kit, while modified or unowned root
|
|
362
427
|
files are preserved and reported as conflicts. The migration never follows a
|
|
363
|
-
symlink or deletes a file whose managed hash no longer matches.
|
|
428
|
+
symlink or deletes a file whose managed hash no longer matches. The migration
|
|
429
|
+
writes and verifies the complete hidden plan, atomically switches the manifest
|
|
430
|
+
authority, and only then cleans owned legacy files. If a process stops between
|
|
431
|
+
those stages, `doctor` reports `E_MIGRATION_INCOMPLETE` and the next `update`
|
|
432
|
+
retries cleanup only when the recorded legacy hash still matches; modified or
|
|
433
|
+
unowned files remain for manual review.
|
|
364
434
|
|
|
365
435
|
### Migrate an existing mdfiles installation
|
|
366
436
|
|
package/THREAT_MODEL.md
CHANGED
|
@@ -12,7 +12,8 @@ remaining trust boundaries and their executable evidence.
|
|
|
12
12
|
| --- | --- | --- | --- | --- | --- |
|
|
13
13
|
| Path traversal | Writes or reads outside the selected target | Target path and every managed relative path | `ensureWithin`, safe-path checks, realpath containment, Windows-drive rejection | A separately privileged process can change the filesystem after validation | `tests/core.test.js`, `tests/portability.test.js`, `tests/fixtures/protocol/invalid/path-traversal.json` |
|
|
14
14
|
| Symlink escape | Redirects a managed path to another directory | Existing target parents and artifact paths | Reject symlinked targets, parents, and destinations before access | The check is not a filesystem lock | `tests/core.test.js`, `tests/portability.test.js`, `tests/fixtures/protocol/invalid/symlink-target.json` |
|
|
15
|
-
| Malicious manifest | Causes unsafe update or false ownership claims | `.forgeloop/manifest.json` | Schema, hash, safe-path, and preserve-flag validation;
|
|
15
|
+
| Malicious manifest | Causes unsafe update or false ownership claims | `.forgeloop/manifest.json` | Schema, managed/legacy hash, safe-path, and preserve-flag validation; incomplete migration remains visible | A trusted operator can still deliberately edit the manifest | `tests/cli.test.js`, `tests/core.test.js`, `tests/hidden-layout.test.js` |
|
|
16
|
+
| Interrupted or concurrent migration cleanup | Leaves a new hidden kit, an old manifest, residual legacy files, or mutates a legacy file after planning | Hidden kit, manifest authority, and legacy root cleanup | Validate all paths, write and verify the hidden plan, atomically switch authority, then revalidate safe paths and recorded ownership hashes immediately before unlink; mismatches are preserved and `doctor` reports `E_MIGRATION_INCOMPLETE` | Hash-before-unlink narrows but cannot eliminate the final OS-level race without filesystem locking; a separately privileged process remains outside the trust boundary | `tests/hidden-layout.test.js`, `README.md` |
|
|
16
17
|
| Malicious work-state | Resumes stale, secret-bearing, or invalid work | `.forgeloop/work-state.json` | Schema/semantic checks, version checks, transition checks, contract/HEAD/artifact freshness, secret scan, size/depth bounds | Freshness cannot prove that an external process did not alter a file immediately afterward | `tests/work-state.test.js`, `tests/checkpoint-freshness.test.js`, `tests/security-limits.test.js` |
|
|
17
18
|
| Forged preparation | Makes an agent's prose claim look like a completed preflight | Contract, route, gate, and preflight artifacts | Canonical SHA-256 fingerprints, guide-declared gate requirements, stale-artifact checks, and `E_*` failures | The CLI cannot stop a separate process from writing project files before preflight | `tests/protocol-artifacts.test.js`, `tests/preflight.test.js` |
|
|
18
19
|
| Chronology rewrite | Hides execution before route, gates, or verification | `.forgeloop/events.ndjson` | Append-only local ledger, sequence numbers, hash chaining, and chronology validation without prompts or hidden reasoning | A privileged process can still replace the ledger after validation | `tests/lifecycle.test.js` |
|
|
@@ -23,6 +24,7 @@ remaining trust boundaries and their executable evidence.
|
|
|
23
24
|
| Artifact content exposure | Sensitive material enters portable artifacts or diagnostics | State, receipt, delegation, evidence, and repository text | Nested key/value detection, shaped marker patterns, scanner coverage, no content echo in errors | Content scanners cannot prove that an unknown encoding is harmless | `tests/security-limits.test.js`, `tests/test_scan_secrets.py`, `scripts/scan_secrets.py` |
|
|
24
25
|
| Stale checkpoint replay | Repeats work against a changed contract or material file | Resume boundary between a saved state and current target | Contract fingerprint, repository fingerprint, required-artifact hashes, protocol version, and conservative revalidation | A contract author must provide the current contract file for comparison | `tests/checkpoint-freshness.test.js`, `tests/cli.test.js` |
|
|
25
26
|
| Unsafe publication claim | Local output is mistaken for push, PR, merge, or deployment | Receipt publication fields and evidence | Publication starts false; each true claim requires matching evidence; no CLI publication action | Remote provider state is outside local verification | `tests/receipt-semantics.test.js`, `README.md` |
|
|
27
|
+
| Release identity drift | A blind run uses a package whose code, tag, commit, or tarball digest differs from the stated release | npm metadata, GitHub tag, and release commit | Read-only verifier requires package version, `gitHead`, tag, tarball URL, SHA-1, and SHA-512 to agree before the prompt starts | Registry and remote state require a fresh check for every release; a documentation commit is not a new npm package | `tests/release-identity.test.js`, `scripts/verify_release_identity.mjs`, `conformance/README.md` |
|
|
26
28
|
| Schema confusion | A malformed or altered schema is treated as available | Shipped and target-local `schemas/` directory | Health checks parse every schema and classify valid, missing, invalid, or unsupported-version | Schema health does not replace review of semantic relationships | `tests/schema-health.test.js`, `src/core/schema-validation.js` |
|
|
27
29
|
| Protocol-version confusion | Old/future artifacts are silently reinterpreted | Cross-artifact protocol boundary | Explicit v1 checks and conformance errors for unsupported or mixed versions | A future protocol needs a deliberate migration implementation | `tests/conformance.test.js`, `tests/fixtures/protocol/invalid/` |
|
|
28
30
|
| Dependency-cycle denial of service | Coordination cannot make progress or spends unbounded work | Delegation dependency graph | Deterministic DFS cycle detection, unknown-reference rejection, and bounded JSON | A caller can still submit many valid tasks; host-level quotas remain external | `tests/delegation.test.js`, `tests/delegation-set.test.js` |
|
|
@@ -32,6 +34,9 @@ remaining trust boundaries and their executable evidence.
|
|
|
32
34
|
|
|
33
35
|
- Safe paths are checked before reading or writing; no protocol field is a
|
|
34
36
|
shell command.
|
|
37
|
+
- A migration never treats `layoutVersion: 2` as authoritative before the
|
|
38
|
+
hidden kit is complete and verified; cleanup follows the authority switch and
|
|
39
|
+
is limited by recorded ownership hashes.
|
|
35
40
|
- A `NOT_VERIFIED` or `BLOCKED` evidence record is never upgraded to observed
|
|
36
41
|
success by a formatter.
|
|
37
42
|
- Local checks do not imply remote publication, merge, deployment, or provider
|
package/conformance/README.md
CHANGED
|
@@ -4,6 +4,30 @@ These scenarios are adapter-facing contracts. They describe requests and the
|
|
|
4
4
|
artifacts a live agent must produce; they do not invoke a model runtime and are
|
|
5
5
|
not part of the deterministic `npm test` execution path.
|
|
6
6
|
|
|
7
|
+
The current published baseline for new runs is
|
|
8
|
+
`@cassiomc1/forgeloop@0.1.9`. Pin that version when preparing a reproducible
|
|
9
|
+
blind run; historical reports retain the exact package version they used.
|
|
10
|
+
|
|
11
|
+
The frozen baseline was verified on 2026-08-13 with this identity:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
package: @cassiomc1/forgeloop@0.1.9
|
|
15
|
+
npm gitHead: 0e48240894671ecda2458b15671020b0a7255e70
|
|
16
|
+
release commit: 0e48240894671ecda2458b15671020b0a7255e70
|
|
17
|
+
GitHub tag: v0.1.9 -> 0e48240894671ecda2458b15671020b0a7255e70
|
|
18
|
+
tarball URL: https://registry.npmjs.org/@cassiomc1/forgeloop/-/forgeloop-0.1.9.tgz
|
|
19
|
+
tarball SHA-1: fef7587ebd878e845e9746b4d049a6fe6ef333c5
|
|
20
|
+
npm SHA-512 integrity: sha512-sCvPEDOYT1svNnA8+zw57dJ97N6sNCUH8eDbM6f3cS6eXLCIFFXRPkCAASA+ZPu5mAKbcNaU1TaFKjojbL3DEA==
|
|
21
|
+
release identity: RELEASE_IDENTITY_VALID
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The repository may contain documentation or executable commits after this
|
|
25
|
+
frozen package. Those commits do not change the package used by the blind run;
|
|
26
|
+
the repository's `0.1.10` candidate includes completion-validation and cleanup
|
|
27
|
+
TOCTOU fixes that are not in `0.1.9`. If a blind run needs those executable
|
|
28
|
+
changes, publish and tag the candidate, then repeat the complete identity check
|
|
29
|
+
before starting the run.
|
|
30
|
+
|
|
7
31
|
Run a scenario in a disposable target using the Standard profile first:
|
|
8
32
|
|
|
9
33
|
```bash
|
|
@@ -43,9 +67,52 @@ diagnostic records belong under [`conformance/runs/`](./runs/); they must not
|
|
|
43
67
|
contain secrets, credentials, hidden reasoning, or unnecessary conversation
|
|
44
68
|
history.
|
|
45
69
|
|
|
70
|
+
## Release identity evidence
|
|
71
|
+
|
|
72
|
+
Every live-run report records the exact published package used by the target.
|
|
73
|
+
Include all of these fields before sending the blind prompt:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
package: @cassiomc1/forgeloop@X.Y.Z
|
|
77
|
+
npm gitHead: <40-character commit SHA>
|
|
78
|
+
release commit: <40-character commit SHA>
|
|
79
|
+
GitHub tag: vX.Y.Z -> <40-character commit SHA>
|
|
80
|
+
tarball URL: https://registry.npmjs.org/...
|
|
81
|
+
tarball SHA-1: <40-character hex digest>
|
|
82
|
+
npm SHA-512 integrity: sha512-<base64 digest>
|
|
83
|
+
release identity: RELEASE_IDENTITY_VALID
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Run the repository's read-only verifier against the exact release commit:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
RELEASE_COMMIT="$(git rev-list -n1 vX.Y.Z)"
|
|
90
|
+
npm run release:identity -- --version X.Y.Z --release-commit "$RELEASE_COMMIT"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Do not interpret a local package version, a green build, or a tarball URL by
|
|
94
|
+
itself as publication proof. If the verifier cannot establish every identity
|
|
95
|
+
field, record `RELEASE_IDENTITY_NOT_VERIFIED` or
|
|
96
|
+
`RELEASE_IDENTITY_INVALID` and do not start the blind run.
|
|
97
|
+
|
|
46
98
|
The complete-website scenario deliberately fails when implementation starts
|
|
47
99
|
before the contract, route, and required gates exist.
|
|
48
100
|
|
|
101
|
+
## Migration compatibility evidence
|
|
102
|
+
|
|
103
|
+
The real published `0.1.6` fixture at
|
|
104
|
+
[`tests/fixtures/legacy-0.1.6/`](../tests/fixtures/legacy-0.1.6/) is frozen
|
|
105
|
+
with package, tarball, SHA-1, SHA-512, `gitHead`, and extraction-date metadata.
|
|
106
|
+
The migration tests run from those local bytes and cover interruptions after
|
|
107
|
+
hidden writes, after hidden verification, after the manifest authority switch,
|
|
108
|
+
and during legacy cleanup. An interrupted target must be diagnosed as
|
|
109
|
+
`E_MIGRATION_INCOMPLETE`; a later `update` may retry only hash-owned cleanup.
|
|
110
|
+
User-modified, unmanaged, and `preserve=true` files remain untouched even when
|
|
111
|
+
that leaves a root residual. ForgeLoop revalidates the recorded ownership hash
|
|
112
|
+
immediately before deleting each managed legacy file. This narrows the race
|
|
113
|
+
window but does not provide OS-level filesystem locking against a separately
|
|
114
|
+
privileged concurrent process.
|
|
115
|
+
|
|
49
116
|
## Autonomous blind-run isolation
|
|
50
117
|
|
|
51
118
|
External workflows may help with local planning, review, tests, or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cassiomc1/forgeloop",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Portable, verifiable instruction kit for AI agents and developers",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
],
|
|
48
48
|
"scripts": {
|
|
49
49
|
"test": "node scripts/run-tests.js",
|
|
50
|
-
"pack:check": "node --test tests/package.test.js"
|
|
50
|
+
"pack:check": "node --test tests/package.test.js",
|
|
51
|
+
"release:identity": "node scripts/verify_release_identity.mjs"
|
|
51
52
|
}
|
|
52
53
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -147,6 +147,23 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
|
|
|
147
147
|
}
|
|
148
148
|
const hasLegacyAlternative = layoutVersion >= LAYOUT_VERSION
|
|
149
149
|
&& entry.legacyRelativePath !== entry.relativePath;
|
|
150
|
+
if (layoutVersion < LAYOUT_VERSION && entry.legacyRelativePath !== entry.relativePath) {
|
|
151
|
+
const hiddenDestination = ensureWithin(target, entry.relativePath);
|
|
152
|
+
try {
|
|
153
|
+
await assertSafePath(target, entry.relativePath);
|
|
154
|
+
if (await fileExists(hiddenDestination)) {
|
|
155
|
+
findings.push(finding(
|
|
156
|
+
"E_MIGRATION_INCOMPLETE",
|
|
157
|
+
"error",
|
|
158
|
+
entry.relativePath,
|
|
159
|
+
"A hidden migration destination exists while the legacy manifest is still authoritative; rerun forgeloop update to recover the transaction.",
|
|
160
|
+
"Run forgeloop update and inspect the result before changing either copy.",
|
|
161
|
+
));
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
findings.push(finding("unsafe-path", "error", entry.relativePath, error.message));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
150
167
|
if (hasLegacyAlternative) {
|
|
151
168
|
try {
|
|
152
169
|
await assertSafePath(target, entry.legacyRelativePath);
|
|
@@ -161,10 +178,11 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
|
|
|
161
178
|
: null;
|
|
162
179
|
if (legacyDestination && await fileExists(legacyDestination)) {
|
|
163
180
|
findings.push(finding(
|
|
164
|
-
"
|
|
165
|
-
"
|
|
181
|
+
"E_MIGRATION_INCOMPLETE",
|
|
182
|
+
"error",
|
|
166
183
|
entry.legacyRelativePath,
|
|
167
|
-
"Canonical file remains in the legacy root layout;
|
|
184
|
+
"Canonical file remains in the legacy root layout; migration is incomplete and the legacy copy was preserved.",
|
|
185
|
+
"Run forgeloop update; it removes the root copy only when the recorded ownership hash still matches.",
|
|
168
186
|
));
|
|
169
187
|
continue;
|
|
170
188
|
}
|
|
@@ -215,10 +233,24 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
|
|
|
215
233
|
if (await fileExists(legacyPath)) {
|
|
216
234
|
if (entry.sourcePath !== "PROJECT_PROFILE.md") {
|
|
217
235
|
findings.push(finding(
|
|
218
|
-
"
|
|
219
|
-
"
|
|
236
|
+
"E_MIGRATION_INCOMPLETE",
|
|
237
|
+
"error",
|
|
238
|
+
entry.legacyRelativePath,
|
|
239
|
+
record.legacySha256
|
|
240
|
+
? "A legacy root copy remains after the manifest authority switch; cleanup is incomplete."
|
|
241
|
+
: "A legacy root copy remains without an ownership proof; it was preserved.",
|
|
242
|
+
"Run forgeloop update; modified or unowned legacy files require manual review and are never deleted silently.",
|
|
243
|
+
));
|
|
244
|
+
}
|
|
245
|
+
if (entry.sourcePath === "PROJECT_PROFILE.md") {
|
|
246
|
+
findings.push(finding(
|
|
247
|
+
"E_MIGRATION_INCOMPLETE",
|
|
248
|
+
"error",
|
|
220
249
|
entry.legacyRelativePath,
|
|
221
|
-
|
|
250
|
+
record.legacySha256
|
|
251
|
+
? "The legacy project profile remains after the manifest authority switch; cleanup is incomplete."
|
|
252
|
+
: "A legacy project profile remains alongside the hidden canonical profile.",
|
|
253
|
+
"Run forgeloop update, then remove the root profile only after verifying ownership and preserved bytes.",
|
|
222
254
|
));
|
|
223
255
|
}
|
|
224
256
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -41,8 +41,12 @@ async function removeEmptyLegacyDirectory(target, relativePath, dryRun) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
|
|
45
|
-
cleanupFiles.
|
|
44
|
+
function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath, expectedLegacySha256) {
|
|
45
|
+
cleanupFiles.set(relativePath, expectedLegacySha256);
|
|
46
|
+
addLegacyCleanupDirectory(cleanupDirectories, relativePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addLegacyCleanupDirectory(cleanupDirectories, relativePath) {
|
|
46
50
|
for (const directory of LEGACY_CLEANUP_DIRECTORIES) {
|
|
47
51
|
if (relativePath === directory || relativePath.startsWith(`${directory}/`)) {
|
|
48
52
|
cleanupDirectories.add(directory);
|
|
@@ -50,12 +54,53 @@ function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
function manifestRecord(sha256Value, preserve, legacySha256Value = null) {
|
|
58
|
+
return {
|
|
59
|
+
sha256: sha256Value,
|
|
60
|
+
preserve,
|
|
61
|
+
...(legacySha256Value ? { legacySha256: legacySha256Value } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function notifyStage(hooks, stage, context) {
|
|
66
|
+
if (typeof hooks?.afterStage === "function") await hooks.afterStage(stage, context);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts }) {
|
|
70
|
+
for (const [relativePath, expectedLegacySha256] of cleanupFiles) {
|
|
71
|
+
if (typeof hooks?.beforeCleanup === "function") {
|
|
72
|
+
await hooks.beforeCleanup(relativePath);
|
|
73
|
+
}
|
|
74
|
+
await assertSafePath(target, relativePath);
|
|
75
|
+
const legacyPath = ensureWithin(target, relativePath);
|
|
76
|
+
if (dryRun || !(await fileExists(legacyPath))) continue;
|
|
77
|
+
const currentLegacySha256 = sha256(await readBytes(legacyPath));
|
|
78
|
+
if (currentLegacySha256 !== expectedLegacySha256) {
|
|
79
|
+
conflicts.push(migrationConflict(
|
|
80
|
+
"E_LEGACY_FILE_MIGRATION_CONFLICT",
|
|
81
|
+
relativePath,
|
|
82
|
+
"Legacy file changed before cleanup; it was preserved.",
|
|
83
|
+
));
|
|
84
|
+
actions.push({ action: "preserve-conflict", path: relativePath, reason: "changed-before-cleanup" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
await unlink(legacyPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const relativePath of cleanupDirectories) {
|
|
91
|
+
if (typeof hooks?.beforeCleanupDirectory === "function") {
|
|
92
|
+
await hooks.beforeCleanupDirectory(relativePath);
|
|
93
|
+
}
|
|
94
|
+
await removeEmptyLegacyDirectory(target, relativePath, dryRun);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks = {} }) {
|
|
54
99
|
const nextManifest = createManifest(packageVersion);
|
|
55
100
|
const actions = [];
|
|
56
101
|
const conflicts = [];
|
|
57
102
|
const writes = [];
|
|
58
|
-
const cleanupFiles = new
|
|
103
|
+
const cleanupFiles = new Map();
|
|
59
104
|
const cleanupDirectories = new Set();
|
|
60
105
|
|
|
61
106
|
// Validate every path before creating the migration plan or touching data.
|
|
@@ -86,6 +131,11 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
|
|
|
86
131
|
|
|
87
132
|
const currentBytes = await readBytes(destination);
|
|
88
133
|
const currentHash = sha256(currentBytes);
|
|
134
|
+
if (legacyRecord && currentHash === sourceHash) {
|
|
135
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
|
|
136
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
89
139
|
if (legacyRecord && currentHash === legacyRecord.sha256) {
|
|
90
140
|
if (currentHash === sourceHash) {
|
|
91
141
|
actions.push({ action: "skip", path: entry.relativePath, reason: "current-shim" });
|
|
@@ -114,32 +164,67 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
|
|
|
114
164
|
if (destinationExists) {
|
|
115
165
|
const currentBytes = await readBytes(destination);
|
|
116
166
|
const currentHash = sha256(currentBytes);
|
|
117
|
-
|
|
167
|
+
const hiddenMatchesSource = currentHash === sourceHash;
|
|
168
|
+
const legacyBytes = legacyExists ? await readBytes(legacyDestination) : null;
|
|
169
|
+
const legacyHash = legacyBytes ? sha256(legacyBytes) : null;
|
|
170
|
+
const unchangedManagedLegacy = Boolean(legacyRecord) && legacyHash === legacyRecord.sha256;
|
|
171
|
+
|
|
172
|
+
if (entry.sourcePath === PROFILE_PATH
|
|
173
|
+
&& legacyExists
|
|
174
|
+
&& legacyRecord
|
|
175
|
+
&& currentHash === legacyHash) {
|
|
176
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "profile-move-resumed" });
|
|
177
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
|
|
178
|
+
nextManifest.files[entry.relativePath] = manifestRecord(currentHash, true, legacyHash);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!hiddenMatchesSource && !destinationRecord && entry.sourcePath !== PROFILE_PATH) {
|
|
118
183
|
conflicts.push(migrationConflict(
|
|
119
184
|
"E_HIDDEN_KIT_MIGRATION_CONFLICT",
|
|
120
185
|
entry.relativePath,
|
|
121
186
|
"Existing hidden kit file is unmanaged and was not overwritten.",
|
|
122
187
|
));
|
|
123
188
|
addAction(actions, dryRun, "preserve-conflict", entry.relativePath, { reason: "hidden-unmanaged" });
|
|
189
|
+
} else if (hiddenMatchesSource && legacyExists && unchangedManagedLegacy) {
|
|
190
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "hidden-ready" });
|
|
191
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
|
|
192
|
+
nextManifest.files[entry.relativePath] = manifestRecord(
|
|
193
|
+
sourceHash,
|
|
194
|
+
entry.sourcePath === PROFILE_PATH,
|
|
195
|
+
legacyHash,
|
|
196
|
+
);
|
|
197
|
+
continue;
|
|
198
|
+
} else if (hiddenMatchesSource && legacyExists) {
|
|
199
|
+
const conflict = entry.sourcePath === PROFILE_PATH
|
|
200
|
+
? migrationConflict(
|
|
201
|
+
"E_PROFILE_MIGRATION_CONFLICT",
|
|
202
|
+
entry.legacyRelativePath,
|
|
203
|
+
"A legacy project profile remains beside the hidden kit without a matching managed hash; both copies were preserved.",
|
|
204
|
+
)
|
|
205
|
+
: migrationConflict(
|
|
206
|
+
"E_LEGACY_FILE_MIGRATION_CONFLICT",
|
|
207
|
+
entry.legacyRelativePath,
|
|
208
|
+
"A legacy file remains beside the hidden kit without a matching managed hash; it was preserved.",
|
|
209
|
+
);
|
|
210
|
+
conflicts.push(conflict);
|
|
211
|
+
addAction(actions, dryRun, "preserve-conflict", entry.legacyRelativePath, { reason: "legacy-residual" });
|
|
124
212
|
} else {
|
|
125
213
|
actions.push({ action: "skip", path: entry.relativePath, reason: "already-present" });
|
|
126
214
|
}
|
|
127
|
-
nextManifest.files[entry.relativePath] =
|
|
128
|
-
|
|
129
|
-
|
|
215
|
+
nextManifest.files[entry.relativePath] = manifestRecord(
|
|
216
|
+
currentHash,
|
|
217
|
+
entry.sourcePath === PROFILE_PATH
|
|
130
218
|
|| !destinationRecord
|
|
131
219
|
|| Boolean(destinationRecord.preserve),
|
|
132
|
-
|
|
220
|
+
);
|
|
133
221
|
continue;
|
|
134
222
|
}
|
|
135
223
|
|
|
136
224
|
if (!legacyExists) {
|
|
137
225
|
addAction(actions, dryRun, "create", entry.relativePath);
|
|
138
226
|
writes.push({ destination, bytes: entry.bytes });
|
|
139
|
-
nextManifest.files[entry.relativePath] =
|
|
140
|
-
sha256: sourceHash,
|
|
141
|
-
preserve: entry.sourcePath === PROFILE_PATH,
|
|
142
|
-
};
|
|
227
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
|
|
143
228
|
continue;
|
|
144
229
|
}
|
|
145
230
|
|
|
@@ -150,24 +235,24 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
|
|
|
150
235
|
if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
|
|
151
236
|
addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
|
|
152
237
|
writes.push({ destination, bytes: legacyBytes });
|
|
153
|
-
|
|
154
|
-
nextManifest.files[entry.relativePath] =
|
|
238
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
|
|
239
|
+
nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
|
|
155
240
|
continue;
|
|
156
241
|
}
|
|
157
242
|
|
|
158
243
|
if (legacyRecord?.preserve && unchangedManaged) {
|
|
159
244
|
addAction(actions, dryRun, "move-preserved", entry.legacyRelativePath, { to: entry.relativePath });
|
|
160
245
|
writes.push({ destination, bytes: legacyBytes });
|
|
161
|
-
|
|
162
|
-
nextManifest.files[entry.relativePath] =
|
|
246
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
|
|
247
|
+
nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
|
|
163
248
|
continue;
|
|
164
249
|
}
|
|
165
250
|
|
|
166
251
|
if (unchangedManaged) {
|
|
167
252
|
addAction(actions, dryRun, "migrate", entry.legacyRelativePath, { to: entry.relativePath });
|
|
168
253
|
writes.push({ destination, bytes: entry.bytes });
|
|
169
|
-
|
|
170
|
-
nextManifest.files[entry.relativePath] =
|
|
254
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
|
|
255
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false, legacyHash);
|
|
171
256
|
continue;
|
|
172
257
|
}
|
|
173
258
|
|
|
@@ -190,35 +275,28 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
|
|
|
190
275
|
reason: legacyRecord ? "managed-modified" : "unmanaged",
|
|
191
276
|
});
|
|
192
277
|
writes.push({ destination, bytes: entry.bytes });
|
|
193
|
-
nextManifest.files[entry.relativePath] =
|
|
194
|
-
sha256: sourceHash,
|
|
195
|
-
preserve: entry.sourcePath === PROFILE_PATH,
|
|
196
|
-
};
|
|
278
|
+
nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, entry.sourcePath === PROFILE_PATH);
|
|
197
279
|
}
|
|
198
280
|
|
|
199
|
-
// Apply all hidden writes and verify their bytes before
|
|
281
|
+
// Apply all hidden writes and verify their bytes before changing manifest authority.
|
|
200
282
|
for (const plan of writes) {
|
|
201
283
|
await writeFileAtomic(plan.destination, plan.bytes, { dryRun });
|
|
202
284
|
if (!dryRun) await verifyWrite(plan.destination, plan.bytes);
|
|
203
285
|
}
|
|
286
|
+
await notifyStage(hooks, "HIDDEN_WRITTEN", { writes: writes.length });
|
|
287
|
+
await notifyStage(hooks, "HIDDEN_VERIFIED", { writes: writes.length });
|
|
204
288
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const legacyPath = ensureWithin(target, relativePath);
|
|
208
|
-
if (!dryRun && await fileExists(legacyPath)) await unlink(legacyPath);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
for (const relativePath of cleanupDirectories) {
|
|
212
|
-
await removeEmptyLegacyDirectory(target, relativePath, dryRun);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// The manifest is written last so layoutVersion 2 is never authoritative before
|
|
216
|
-
// hidden destinations and adapter decisions have been applied.
|
|
289
|
+
// Atomic manifest replacement is the authority switch. Cleanup follows it and
|
|
290
|
+
// remains recoverable because managed legacy hashes are retained in the record.
|
|
217
291
|
await writeManifest(target, nextManifest, { dryRun });
|
|
292
|
+
await notifyStage(hooks, "MANIFEST_SWITCHED", { cleanupFiles: [...cleanupFiles.keys()] });
|
|
293
|
+
await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts });
|
|
294
|
+
await notifyStage(hooks, "LEGACY_CLEANED", { cleanupFiles: [...cleanupFiles.keys()] });
|
|
295
|
+
await notifyStage(hooks, "COMPLETE", { cleanupFiles: [...cleanupFiles.keys()] });
|
|
218
296
|
return { actions, conflicts, manifest: nextManifest };
|
|
219
297
|
}
|
|
220
298
|
|
|
221
|
-
export async function runUpdate({ target, dryRun, packageRoot, packageVersion }) {
|
|
299
|
+
export async function runUpdate({ target, dryRun, packageRoot, packageVersion, hooks = {} }) {
|
|
222
300
|
const currentManifest = await readManifest(target);
|
|
223
301
|
if (!currentManifest) {
|
|
224
302
|
throw new Error("No .forgeloop/manifest.json found; run forgeloop init first.");
|
|
@@ -226,13 +304,15 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
226
304
|
|
|
227
305
|
const entries = await readTemplateEntries(packageRoot);
|
|
228
306
|
if ((currentManifest.layoutVersion ?? 1) < LAYOUT_VERSION) {
|
|
229
|
-
return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries });
|
|
307
|
+
return migrateLegacyLayout({ target, dryRun, packageVersion, currentManifest, entries, hooks });
|
|
230
308
|
}
|
|
231
309
|
|
|
232
310
|
const nextManifest = structuredClone(currentManifest);
|
|
233
311
|
const actions = [];
|
|
234
312
|
const conflicts = [];
|
|
235
313
|
const plans = [];
|
|
314
|
+
const cleanupFiles = new Map();
|
|
315
|
+
const cleanupDirectories = new Set();
|
|
236
316
|
const pruneActions = [];
|
|
237
317
|
const shippedPaths = new Set(entries.map((entry) => entry.relativePath));
|
|
238
318
|
|
|
@@ -249,10 +329,34 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
249
329
|
for (const entry of entries) {
|
|
250
330
|
const destination = ensureWithin(target, entry.relativePath);
|
|
251
331
|
await assertSafePath(target, entry.relativePath);
|
|
332
|
+
const hasLegacyAlternative = entry.legacyRelativePath !== entry.relativePath;
|
|
333
|
+
const legacyDestination = hasLegacyAlternative
|
|
334
|
+
? ensureWithin(target, entry.legacyRelativePath)
|
|
335
|
+
: null;
|
|
336
|
+
if (hasLegacyAlternative) await assertSafePath(target, entry.legacyRelativePath);
|
|
252
337
|
const sourceHash = sha256(entry.bytes);
|
|
253
338
|
const record = currentManifest.files[entry.relativePath];
|
|
254
339
|
const exists = await fileExists(destination);
|
|
255
340
|
|
|
341
|
+
if (hasLegacyAlternative && record?.legacySha256) {
|
|
342
|
+
addLegacyCleanupDirectory(cleanupDirectories, entry.legacyRelativePath);
|
|
343
|
+
}
|
|
344
|
+
if (hasLegacyAlternative && await fileExists(legacyDestination)) {
|
|
345
|
+
const legacyHash = sha256(await readBytes(legacyDestination));
|
|
346
|
+
if (record?.legacySha256 && legacyHash === record.legacySha256) {
|
|
347
|
+
addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, record.legacySha256);
|
|
348
|
+
} else {
|
|
349
|
+
conflicts.push({
|
|
350
|
+
code: "E_LEGACY_FILE_MIGRATION_CONFLICT",
|
|
351
|
+
path: entry.legacyRelativePath,
|
|
352
|
+
message: record?.legacySha256
|
|
353
|
+
? "Legacy file changed after the migration authority switch; it was preserved."
|
|
354
|
+
: "Legacy root file remains without ownership proof; it was preserved.",
|
|
355
|
+
});
|
|
356
|
+
actions.push({ action: "preserve-conflict", path: entry.legacyRelativePath, reason: "legacy-residual" });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
256
360
|
if (!exists) {
|
|
257
361
|
plans.push({
|
|
258
362
|
action: dryRun ? "would-create" : "created",
|
|
@@ -261,6 +365,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
261
365
|
record: {
|
|
262
366
|
sha256: sourceHash,
|
|
263
367
|
preserve: entry.sourcePath === PROFILE_PATH,
|
|
368
|
+
...(record?.legacySha256 ? { legacySha256: record.legacySha256 } : {}),
|
|
264
369
|
},
|
|
265
370
|
});
|
|
266
371
|
continue;
|
|
@@ -318,5 +423,6 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion })
|
|
|
318
423
|
nextManifest.packageName = PACKAGE_NAME;
|
|
319
424
|
nextManifest.packageVersion = packageVersion;
|
|
320
425
|
await writeManifest(target, nextManifest, { dryRun });
|
|
426
|
+
await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts });
|
|
321
427
|
return { actions, conflicts, manifest: nextManifest };
|
|
322
428
|
}
|
package/src/core/checks.js
CHANGED
|
@@ -92,24 +92,35 @@ export function assertCheckList(value, label = "checks") {
|
|
|
92
92
|
return value;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
function requiredChecksSatisfiedBy(checks, requiredValues, selector, { allowInferred = false } = {}) {
|
|
96
96
|
assertCheckList(checks);
|
|
97
|
-
if (!Array.isArray(
|
|
98
|
-
const byId = new Map(checks.map((check) => [check.id, check]));
|
|
97
|
+
if (!Array.isArray(requiredValues)) throw checkError("E_CHECK_INVALID", "required values must be an array");
|
|
99
98
|
const errors = [];
|
|
100
|
-
for (const
|
|
101
|
-
const
|
|
99
|
+
for (const value of requiredValues) {
|
|
100
|
+
const candidates = checks.filter((check) => selector(check) === value);
|
|
101
|
+
const check = candidates.find((candidate) => candidate.status === "passed"
|
|
102
|
+
&& (allowInferred || candidate.evidenceKind === "OBSERVED"))
|
|
103
|
+
?? candidates.find((candidate) => candidate.status === "passed")
|
|
104
|
+
?? candidates[0];
|
|
102
105
|
if (!check) {
|
|
103
|
-
errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${
|
|
106
|
+
errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${value}`, [value]));
|
|
104
107
|
continue;
|
|
105
108
|
}
|
|
106
109
|
if (check.status !== "passed") {
|
|
107
|
-
errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${
|
|
110
|
+
errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${value}`, [value]));
|
|
108
111
|
continue;
|
|
109
112
|
}
|
|
110
113
|
if (!allowInferred && check.evidenceKind !== "OBSERVED") {
|
|
111
|
-
errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${
|
|
114
|
+
errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${value}`, [value]));
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
return errors;
|
|
115
118
|
}
|
|
119
|
+
|
|
120
|
+
export function requiredChecksSatisfied(checks, requiredIds, options = {}) {
|
|
121
|
+
return requiredChecksSatisfiedBy(checks, requiredIds, (check) => check.id, options);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function requiredChecksSatisfiedForRequirements(checks, requiredRequirements, options = {}) {
|
|
125
|
+
return requiredChecksSatisfiedBy(checks, requiredRequirements, (check) => check.requirement, options);
|
|
126
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ARTIFACT_PATHS, canonicalFingerprint } from "./artifacts.js";
|
|
2
|
-
import { assertCheckList,
|
|
2
|
+
import { assertCheckList, requiredChecksSatisfiedForRequirements } from "./checks.js";
|
|
3
3
|
import { assertCoverageList, coverageForRequirements } from "./coverage.js";
|
|
4
4
|
import { assertEvidenceList } from "./evidence.js";
|
|
5
5
|
|
|
@@ -86,7 +86,7 @@ export function completionRelationshipErrors({
|
|
|
86
86
|
addAssertion(errors, () => assertCheckList(receipt.checks, "receipt.checks"), "E_CHECK_INVALID", [ARTIFACT_PATHS.receipt]);
|
|
87
87
|
addAssertion(errors, () => assertEvidenceList(receipt.evidence ?? [], "receipt.evidence"), "E_EVIDENCE_INVALID", [ARTIFACT_PATHS.receipt]);
|
|
88
88
|
if (requireRequiredChecks) {
|
|
89
|
-
for (const error of
|
|
89
|
+
for (const error of requiredChecksSatisfiedForRequirements(receipt.checks, requiredEvidence)) {
|
|
90
90
|
errors.push(issue(error.code, error.message, [ARTIFACT_PATHS.receipt]));
|
|
91
91
|
}
|
|
92
92
|
}
|
package/src/core/manifest.js
CHANGED
|
@@ -49,6 +49,10 @@ function validateManifest(manifest) {
|
|
|
49
49
|
if (typeof record.preserve !== "boolean") {
|
|
50
50
|
throw new Error(`Invalid manifest preserve flag for ${relativePath}`);
|
|
51
51
|
}
|
|
52
|
+
if (record.legacySha256 !== undefined
|
|
53
|
+
&& (typeof record.legacySha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.legacySha256))) {
|
|
54
|
+
throw new Error(`Invalid manifest legacy hash for ${relativePath}`);
|
|
55
|
+
}
|
|
52
56
|
}
|
|
53
57
|
return manifest;
|
|
54
58
|
}
|