@echopath-labs/forgerail 0.1.0-alpha.2 → 0.1.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +2 -3
- package/CHANGELOG.md +22 -1
- package/CODE_OF_CONDUCT.md +34 -0
- package/CONTRIBUTING.md +68 -4
- package/README.md +126 -49
- package/README.zh-CN.md +131 -28
- package/SECURITY.md +48 -4
- package/SUPPORT.md +37 -0
- package/adapters/claude-code.json +6 -1
- package/adapters/codex.json +6 -0
- package/adapters/cursor.json +5 -0
- package/contracts/adoption-plan.schema.json +39 -18
- package/contracts/effective-profile.schema.json +4 -4
- package/contracts/host-adapter.schema.json +66 -4
- package/contracts/host-binding-receipt.schema.json +1 -1
- package/contracts/launch-contract.schema.json +38 -2
- package/contracts/profile-change-candidate.schema.json +1 -1
- package/contracts/return-receipt.schema.json +1 -1
- package/contracts/task-envelope.schema.json +1 -1
- package/directory/README.md +1 -1
- package/directory/release-notes-alpha3.md +7 -0
- package/directory/release-notes-alpha4.md +9 -0
- package/directory/submission-candidate.json +5 -6
- package/docs/adoption.md +63 -26
- package/docs/adoption.zh-CN.md +62 -25
- package/docs/architecture-acceptance.md +1 -1
- package/docs/composable-autonomy.zh-CN.md +16 -22
- package/docs/installation.md +72 -40
- package/docs/installation.zh-CN.md +90 -31
- package/docs/release-alpha3.md +25 -0
- package/docs/release-alpha3.zh-CN.md +25 -0
- package/docs/release-alpha4.md +33 -0
- package/docs/release-alpha4.zh-CN.md +33 -0
- package/package.json +7 -3
- package/scripts/adoption-closeout-regressions.mjs +100 -0
- package/scripts/build-universal-directory-candidate.mjs +2 -2
- package/scripts/disposable-consumer.mjs +11 -18
- package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
- package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
- package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
- package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
- package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
- package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
- package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
- package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
- package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
- package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
- package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
- package/scripts/fixtures/open-source-docs/cases.json +65 -0
- package/scripts/forgerail.mjs +61 -16
- package/scripts/integrity-regressions.mjs +1261 -0
- package/scripts/lib/adoption.mjs +666 -51
- package/scripts/lib/bounded-read.mjs +80 -0
- package/scripts/lib/composition.mjs +77 -7
- package/scripts/lib/contracts.mjs +126 -40
- package/scripts/lib/diagnosis.mjs +146 -39
- package/scripts/shadow-comparison.mjs +52 -34
- package/scripts/validate-open-source-docs.mjs +132 -0
- package/scripts/validate-release.mjs +77 -13
- package/scripts/validate-universal-directory.mjs +17 -5
- package/skills/forgerail/references/adoption.md +2 -2
- package/skills/forgerail/references/contracts.md +2 -2
- package/scripts/lib/bundle.mjs +0 -77
|
@@ -1,65 +1,173 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
opendirSync,
|
|
4
|
+
realpathSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { loadHostAdapters } from "./adoption.mjs";
|
|
9
|
+
import { inspectBoundedPath } from "./bounded-read.mjs";
|
|
10
|
+
|
|
11
|
+
const defaultPluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
12
|
+
const maximumDiagnosticDirectoryEntries = 4096;
|
|
3
13
|
|
|
4
14
|
function observed(id, source, value) {
|
|
5
15
|
return { id, kind: "observed_fact", source, value };
|
|
6
16
|
}
|
|
7
17
|
|
|
8
|
-
function
|
|
9
|
-
|
|
18
|
+
function confined(root, target) {
|
|
19
|
+
const value = relative(root, target);
|
|
20
|
+
return value === "" || (
|
|
21
|
+
!isAbsolute(value)
|
|
22
|
+
&& !/^[a-zA-Z]:/.test(value)
|
|
23
|
+
&& value !== ".."
|
|
24
|
+
&& !value.startsWith(`..${sep}`)
|
|
25
|
+
&& !value.startsWith("/")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sameFile(left, right) {
|
|
30
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
10
31
|
}
|
|
11
32
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
33
|
+
function safeJson(root, path) {
|
|
34
|
+
const inspected = inspectBoundedPath(root, path, { finalKind: "file", read: true });
|
|
35
|
+
if (inspected.state === "absent") return { state: "absent", value: null, error: null };
|
|
36
|
+
if (inspected.state !== "available") return { state: "unavailable", value: null, error: inspected.state };
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(inspected.content);
|
|
39
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
40
|
+
return { state: "malformed", value: null, error: "invalid-root-shape" };
|
|
41
|
+
}
|
|
42
|
+
return { state: "available", value, error: null };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { state: "malformed", value: null, error: error instanceof SyntaxError ? "invalid-json" : "unreadable" };
|
|
45
|
+
}
|
|
15
46
|
}
|
|
16
47
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
48
|
+
function hasMarkdownRecord(root, path) {
|
|
49
|
+
const inspected = inspectBoundedPath(root, path, { finalKind: "directory" });
|
|
50
|
+
if (inspected.state !== "available") return false;
|
|
51
|
+
const directory = resolve(root, path);
|
|
52
|
+
let before;
|
|
53
|
+
let observed;
|
|
54
|
+
let handle;
|
|
55
|
+
try {
|
|
56
|
+
before = lstatSync(directory);
|
|
57
|
+
observed = realpathSync(directory);
|
|
58
|
+
const after = lstatSync(observed);
|
|
59
|
+
if (!confined(root, observed) || after.isSymbolicLink() || !sameFile(before, after) || !after.isDirectory()) return false;
|
|
60
|
+
handle = opendirSync(observed);
|
|
61
|
+
const openedObserved = realpathSync(directory);
|
|
62
|
+
const opened = lstatSync(openedObserved);
|
|
63
|
+
if (openedObserved !== observed || !confined(root, openedObserved) || !sameFile(after, opened) || !opened.isDirectory()) return false;
|
|
64
|
+
const names = [];
|
|
65
|
+
while (true) {
|
|
66
|
+
const entry = handle.readSync();
|
|
67
|
+
if (entry === null) break;
|
|
68
|
+
if (names.length >= maximumDiagnosticDirectoryEntries) return false;
|
|
69
|
+
names.push(entry.name);
|
|
70
|
+
}
|
|
71
|
+
const finalObserved = realpathSync(directory);
|
|
72
|
+
const final = lstatSync(finalObserved);
|
|
73
|
+
if (finalObserved !== observed || !confined(root, finalObserved) || !sameFile(after, final) || !final.isDirectory()) return false;
|
|
74
|
+
return names.some((name) => typeof name === "string"
|
|
75
|
+
&& name.toLowerCase().endsWith(".md")
|
|
76
|
+
&& inspectBoundedPath(root, `${path}/${name}`, { finalKind: "file", verify: true }).state === "available");
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
} finally {
|
|
80
|
+
try { handle?.closeSync(); } catch {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function diagnoseWorkspace(workspace, pluginRoot = defaultPluginRoot) {
|
|
85
|
+
let root;
|
|
86
|
+
try { root = realpathSync(resolve(workspace)); }
|
|
87
|
+
catch { throw new Error("workspace must be an existing directory"); }
|
|
88
|
+
const rootMetadata = lstatSync(root);
|
|
89
|
+
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw new Error("workspace must be an existing directory");
|
|
90
|
+
const registry = loadHostAdapters(pluginRoot);
|
|
91
|
+
if (!registry.valid) throw new Error(`host adapter registry is invalid: ${registry.errors.join("; ")}`);
|
|
20
92
|
const evidence = [];
|
|
21
93
|
const recommendations = [];
|
|
22
94
|
const confirmationRequired = [];
|
|
95
|
+
const gaps = [];
|
|
96
|
+
|
|
97
|
+
const hostAdapters = registry.adapters.map((adapter) => {
|
|
98
|
+
const binding = inspectBoundedPath(root, adapter.bindingTarget, { finalKind: "file", read: true });
|
|
99
|
+
const detected = adapter.detectionTargets.some((path) => inspectBoundedPath(root, path).present);
|
|
100
|
+
if (binding.present && binding.state !== "available") gaps.push(`host-binding-unavailable:${adapter.id}`);
|
|
101
|
+
return {
|
|
102
|
+
id: adapter.id,
|
|
103
|
+
status: adapter.status,
|
|
104
|
+
target: adapter.bindingTarget,
|
|
105
|
+
observed: detected || binding.present,
|
|
106
|
+
readState: binding.state,
|
|
107
|
+
managedBindingObserved: binding.state === "available" && binding.content.includes(`<!-- ${adapter.managedMarker}:start -->`),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
23
110
|
|
|
24
|
-
for (const
|
|
25
|
-
if (
|
|
111
|
+
for (const adapter of hostAdapters) {
|
|
112
|
+
if (adapter.readState === "available") evidence.push(observed(`instructions:${adapter.target}`, adapter.target, "available"));
|
|
113
|
+
}
|
|
114
|
+
if (inspectBoundedPath(root, "README.md", { finalKind: "file" }).state === "available") {
|
|
115
|
+
evidence.push(observed("instructions:README.md", "README.md", "available"));
|
|
26
116
|
}
|
|
27
117
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const managedBindingObserved = hostAdapters.some((adapter) => {
|
|
34
|
-
if (!adapter.observed) return false;
|
|
35
|
-
try { return readFileSync(resolve(root, adapter.target), "utf8").includes(`forgerail:binding:${adapter.id}:v1:start`); } catch { return false; }
|
|
36
|
-
});
|
|
37
|
-
const adoptionLevel = existsSync(resolve(root, ".forgerail"))
|
|
38
|
-
? "persisted-governance"
|
|
39
|
-
: existsSync(resolve(root, "FORGERAIL.md")) || managedBindingObserved
|
|
40
|
-
? "lightweight-adoption"
|
|
41
|
-
: "plugin-only";
|
|
42
|
-
evidence.push(observed("host-adapters", "bounded host instruction paths", hostAdapters));
|
|
118
|
+
const managedBindingObserved = hostAdapters.some((adapter) => adapter.managedBindingObserved);
|
|
119
|
+
const persisted = inspectBoundedPath(root, ".forgerail").state === "available";
|
|
120
|
+
const portableContract = inspectBoundedPath(root, "FORGERAIL.md", { finalKind: "file" }).state === "available";
|
|
121
|
+
const adoptionLevel = persisted ? "persisted-governance" : portableContract || managedBindingObserved ? "lightweight-adoption" : "plugin-only";
|
|
122
|
+
evidence.push(observed("host-adapters", "registry-owned bounded host instruction paths", hostAdapters));
|
|
43
123
|
evidence.push(observed("forgerail-adoption-level", "bounded ForgeRail markers", adoptionLevel));
|
|
44
124
|
|
|
45
125
|
const recordSystems = [];
|
|
46
|
-
if (
|
|
47
|
-
|
|
126
|
+
if (inspectBoundedPath(root, "openspec").state === "available") recordSystems.push({ type: "openspec", source: "openspec/" });
|
|
127
|
+
const specify = inspectBoundedPath(root, ".specify").state === "available";
|
|
128
|
+
const specs = inspectBoundedPath(root, "specs").state === "available";
|
|
129
|
+
if (specify || specs) recordSystems.push({ type: "spec-kit-or-spec-directory", source: specify ? ".specify/" : "specs/" });
|
|
48
130
|
for (const directory of ["docs/adr", "docs/adrs", "adr", "adrs", "decisions"]) {
|
|
49
|
-
if (
|
|
131
|
+
if (hasMarkdownRecord(root, directory)) recordSystems.push({ type: "markdown-adr", source: `${directory}/` });
|
|
50
132
|
}
|
|
51
|
-
if (
|
|
133
|
+
if (hasMarkdownRecord(root, "docs") && recordSystems.length === 0) recordSystems.push({ type: "markdown-docs", source: "docs/" });
|
|
52
134
|
evidence.push(observed("record-systems", "bounded well-known paths", recordSystems));
|
|
53
135
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
if (packageJson
|
|
136
|
+
const packageJson = safeJson(root, "package.json");
|
|
137
|
+
if (packageJson.state === "available") evidence.push(observed("package-scripts", "package.json", Object.keys(packageJson.value.scripts ?? {}).sort()));
|
|
138
|
+
else if (packageJson.state === "malformed" || packageJson.state === "unavailable") {
|
|
139
|
+
const state = packageJson.state === "malformed" ? "malformed" : "unavailable";
|
|
140
|
+
evidence.push(observed("package-metadata", "package.json", { state, reason: packageJson.error }));
|
|
141
|
+
gaps.push(packageJson.state === "malformed" ? "package-metadata-malformed" : "package-metadata-unavailable");
|
|
142
|
+
recommendations.push({
|
|
143
|
+
kind: "recommendation",
|
|
144
|
+
priority: "P1",
|
|
145
|
+
reason: packageJson.state === "malformed"
|
|
146
|
+
? "package.json exists but is not usable as object metadata."
|
|
147
|
+
: "package.json is not a bounded readable regular file.",
|
|
148
|
+
options: [packageJson.state === "malformed"
|
|
149
|
+
? "repair package.json before relying on package-script observations"
|
|
150
|
+
: "replace the unsafe package.json entry with a reviewed regular file before relying on package-script observations"],
|
|
151
|
+
});
|
|
152
|
+
confirmationRequired.push("Confirm whether unavailable package metadata should block the intended task.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (gaps.some((gap) => gap.startsWith("host-binding-unavailable:"))) {
|
|
156
|
+
recommendations.push({
|
|
157
|
+
kind: "recommendation",
|
|
158
|
+
priority: "P1",
|
|
159
|
+
reason: "One or more registered Host bindings are present but cannot be read within the no-follow regular-file boundary.",
|
|
160
|
+
options: ["inspect the named Host binding and replace unsafe entries only after human confirmation"],
|
|
161
|
+
});
|
|
162
|
+
confirmationRequired.push("Confirm how each unavailable Host binding should be repaired before adoption.");
|
|
163
|
+
}
|
|
57
164
|
|
|
58
|
-
if (
|
|
59
|
-
const skillRoots = [".codex/skills", ".agents/skills"].filter((path) =>
|
|
165
|
+
if (inspectBoundedPath(root, ".git").state === "available") evidence.push(observed("git-root", ".git/", "available"));
|
|
166
|
+
const skillRoots = [".codex/skills", ".agents/skills"].filter((path) => inspectBoundedPath(root, path).state === "available");
|
|
60
167
|
evidence.push(observed("skill-roots", "bounded well-known paths", skillRoots));
|
|
61
168
|
|
|
62
169
|
if (recordSystems.length === 0) {
|
|
170
|
+
gaps.push("durable-record-practice-not-observed");
|
|
63
171
|
recommendations.push({
|
|
64
172
|
kind: "recommendation",
|
|
65
173
|
priority: "P1",
|
|
@@ -73,10 +181,9 @@ export function diagnoseWorkspace(workspace) {
|
|
|
73
181
|
schemaVersion: "1.0",
|
|
74
182
|
mode: "read-only",
|
|
75
183
|
workspace: basename(root),
|
|
76
|
-
workspacePath: root,
|
|
77
184
|
evidence,
|
|
78
185
|
inheritedHabits: recordSystems,
|
|
79
|
-
gaps
|
|
186
|
+
gaps,
|
|
80
187
|
recommendations,
|
|
81
188
|
confirmationRequired,
|
|
82
189
|
mutations: [],
|
|
@@ -85,7 +192,7 @@ export function diagnoseWorkspace(workspace) {
|
|
|
85
192
|
recommendedLevel: adoptionLevel,
|
|
86
193
|
changeRecommended: false,
|
|
87
194
|
reason: "ForgeRail keeps the minimum observed adoption level unless the user requests durable adoption or concrete evidence justifies escalation.",
|
|
88
|
-
planCommandAvailable: "forgerail adoption-plan --workspace .
|
|
195
|
+
planCommandAvailable: "forgerail adoption-plan --workspace .",
|
|
89
196
|
persistedGovernanceGeneration: "deferred",
|
|
90
197
|
},
|
|
91
198
|
fullHealthReviewRecommended: evidence.filter((item) => item.id.startsWith("instructions:")).length > 2,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
|
|
7
7
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
8
|
const core = readFileSync(resolve(root, "skills/forgerail/SKILL.md"), "utf8");
|
|
@@ -23,76 +23,94 @@ function externalSkill(plugin, skill = plugin) {
|
|
|
23
23
|
const rulesets = externalSkill("forgerail-github-rulesets");
|
|
24
24
|
const releaseSafety = externalSkill("forgerail-release-safety");
|
|
25
25
|
const threadClosure = externalSkill("forgerail-thread-closure");
|
|
26
|
-
const
|
|
26
|
+
const expectedAgwEvidence = Object.freeze({
|
|
27
|
+
featureBranchRecords: ["smallest child workspace", "durable records", "git status"],
|
|
28
|
+
dirtyWorktreePreservation: ["Preserve user changes"],
|
|
29
|
+
existingRecordHabit: ["existing durable record system"],
|
|
30
|
+
workspaceHealth: ["Workspace Health Review"],
|
|
31
|
+
githubRulesets: ["project-specific workflow or safety checklist"],
|
|
32
|
+
releaseSafety: ["project-specific release or operations checklist"],
|
|
33
|
+
threadClosure: ["machine-readable closeout"],
|
|
34
|
+
});
|
|
27
35
|
|
|
28
|
-
const
|
|
36
|
+
const scenarioDefinitions = [
|
|
29
37
|
{
|
|
30
38
|
id: "feature-branch-records",
|
|
31
|
-
agwEvidence:
|
|
39
|
+
agwEvidence: expectedAgwEvidence.featureBranchRecords,
|
|
32
40
|
forgerailEvidence: ["smallest owner workspace", "Task Envelope", "existing habits"],
|
|
33
|
-
source: `${
|
|
41
|
+
source: `${core}\n${diagnosis}`,
|
|
34
42
|
status: "covered",
|
|
35
43
|
},
|
|
36
44
|
{
|
|
37
45
|
id: "dirty-worktree-preservation",
|
|
38
|
-
agwEvidence:
|
|
46
|
+
agwEvidence: expectedAgwEvidence.dirtyWorktreePreservation,
|
|
39
47
|
forgerailEvidence: ["preserve unrelated user changes", "dirty-worktree state"],
|
|
40
|
-
source:
|
|
48
|
+
source: core,
|
|
41
49
|
status: "covered",
|
|
42
50
|
},
|
|
43
51
|
{
|
|
44
52
|
id: "markdown-existing-habit",
|
|
45
|
-
agwEvidence:
|
|
53
|
+
agwEvidence: expectedAgwEvidence.existingRecordHabit,
|
|
46
54
|
forgerailEvidence: ["existing habits", "OpenSpec may be a preferred example"],
|
|
47
|
-
source:
|
|
55
|
+
source: diagnosis,
|
|
48
56
|
status: "covered",
|
|
49
57
|
},
|
|
50
58
|
{
|
|
51
59
|
id: "workspace-health",
|
|
52
|
-
agwEvidence:
|
|
60
|
+
agwEvidence: expectedAgwEvidence.workspaceHealth,
|
|
53
61
|
forgerailEvidence: ["first built-in ForgeRail Capability Pack", "Analyze First"],
|
|
54
|
-
source:
|
|
62
|
+
source: health,
|
|
55
63
|
status: "covered-with-follow-up",
|
|
56
64
|
},
|
|
57
65
|
{
|
|
58
66
|
id: "github-rulesets-read-first",
|
|
59
|
-
agwEvidence:
|
|
67
|
+
agwEvidence: expectedAgwEvidence.githubRulesets,
|
|
60
68
|
forgerailEvidence: ["read-only diagnosis", "Stop until the user explicitly approves"],
|
|
61
|
-
source:
|
|
69
|
+
source: rulesets,
|
|
62
70
|
status: "covered",
|
|
63
71
|
},
|
|
64
72
|
{
|
|
65
73
|
id: "release-safety-project-runbook",
|
|
66
|
-
agwEvidence:
|
|
74
|
+
agwEvidence: expectedAgwEvidence.releaseSafety,
|
|
67
75
|
forgerailEvidence: ["project-owned release runbook", "does not contain publish, deploy"],
|
|
68
|
-
source:
|
|
76
|
+
source: releaseSafety,
|
|
69
77
|
status: "covered",
|
|
70
78
|
},
|
|
71
79
|
{
|
|
72
80
|
id: "evidence-first-thread-closure",
|
|
73
|
-
agwEvidence:
|
|
81
|
+
agwEvidence: expectedAgwEvidence.threadClosure,
|
|
74
82
|
forgerailEvidence: ["Keep closeout incomplete", "Do not implement follow-up work"],
|
|
75
|
-
source: `${
|
|
83
|
+
source: `${core}\n${threadClosure}`,
|
|
76
84
|
status: "covered",
|
|
77
85
|
},
|
|
78
86
|
];
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
export function evaluateShadowComparison(overrides = {}, baseline = frozen) {
|
|
89
|
+
const baselineSource = JSON.stringify(baseline);
|
|
90
|
+
const scenarios = scenarioDefinitions.map((definition) => {
|
|
91
|
+
const source = overrides[definition.id] ?? definition.source;
|
|
92
|
+
const scenario = { ...definition };
|
|
93
|
+
scenario.missingAgw = scenario.agwEvidence.filter((phrase) => !baselineSource.toLocaleLowerCase("en-US").includes(phrase.toLocaleLowerCase("en-US")));
|
|
94
|
+
scenario.missingForgeRail = scenario.forgerailEvidence.filter((phrase) => !source.toLocaleLowerCase("en-US").includes(phrase.toLocaleLowerCase("en-US")));
|
|
95
|
+
scenario.passed = scenario.missingAgw.length === 0 && scenario.missingForgeRail.length === 0 && scenario.status !== "unresolved";
|
|
96
|
+
delete scenario.source;
|
|
97
|
+
return scenario;
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: "1.0",
|
|
101
|
+
agwBaseline: "plugins/agent-workflow-governance@0.2.0-canonical",
|
|
102
|
+
forgeRailCandidate: "plugins/forgerail@0.1.0-alpha.4-canonical",
|
|
103
|
+
scenarios,
|
|
104
|
+
covered: scenarios.filter((item) => item.passed).length,
|
|
105
|
+
unresolved: scenarios.filter((item) => !item.passed).map((item) => item.id),
|
|
106
|
+
behaviorCoverageReady: scenarios.every((item) => item.passed),
|
|
107
|
+
migrationReady: false,
|
|
108
|
+
migrationBlockers: ["real compatibility-period canaries are incomplete", "AGW lifecycle change is not approved"],
|
|
109
|
+
};
|
|
85
110
|
}
|
|
86
111
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
covered: scenarios.filter((item) => item.passed).length,
|
|
93
|
-
unresolved: scenarios.filter((item) => !item.passed).map((item) => item.id),
|
|
94
|
-
behaviorCoverageReady: scenarios.every((item) => item.passed),
|
|
95
|
-
migrationReady: false,
|
|
96
|
-
migrationBlockers: ["usable ForgeRail prerelease is not published", "real compatibility-period canaries are incomplete", "AGW lifecycle change is not approved"],
|
|
97
|
-
};
|
|
98
|
-
console.log(JSON.stringify(result, null, 2));
|
|
112
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
113
|
+
const result = evaluateShadowComparison();
|
|
114
|
+
console.log(JSON.stringify(result, null, 2));
|
|
115
|
+
if (!result.behaviorCoverageReady) process.exitCode = 1;
|
|
116
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, extname, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const checks = [];
|
|
9
|
+
const failures = [];
|
|
10
|
+
|
|
11
|
+
const requiredFiles = [
|
|
12
|
+
"README.md",
|
|
13
|
+
"README.zh-CN.md",
|
|
14
|
+
"CONTRIBUTING.md",
|
|
15
|
+
"SECURITY.md",
|
|
16
|
+
"CODE_OF_CONDUCT.md",
|
|
17
|
+
"SUPPORT.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"docs/installation.md",
|
|
20
|
+
"docs/installation.zh-CN.md",
|
|
21
|
+
"docs/adoption.md",
|
|
22
|
+
"docs/adoption.zh-CN.md",
|
|
23
|
+
"docs/composable-autonomy.zh-CN.md",
|
|
24
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
25
|
+
".github/ISSUE_TEMPLATE/bug_report.yml",
|
|
26
|
+
".github/ISSUE_TEMPLATE/feature_request.yml",
|
|
27
|
+
".github/ISSUE_TEMPLATE/documentation.yml",
|
|
28
|
+
".github/ISSUE_TEMPLATE/config.yml"
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const publicTextFiles = requiredFiles.filter((path) => extname(path) === ".md" || extname(path) === ".yml");
|
|
32
|
+
const entryFiles = ["README.md", "README.zh-CN.md", "docs/installation.md", "docs/installation.zh-CN.md", "docs/adoption.md", "docs/adoption.zh-CN.md"];
|
|
33
|
+
const issueForms = [
|
|
34
|
+
".github/ISSUE_TEMPLATE/bug_report.yml",
|
|
35
|
+
".github/ISSUE_TEMPLATE/feature_request.yml",
|
|
36
|
+
".github/ISSUE_TEMPLATE/documentation.yml"
|
|
37
|
+
];
|
|
38
|
+
const skills = ["$forgerail", "$forgerail-workspace-diagnosis", "$workspace-health-review", "$architecture-convergence-audit"];
|
|
39
|
+
const exactInstall = "codex plugin marketplace add echopath-labs/forgerail --ref v0.1.0-alpha.4";
|
|
40
|
+
|
|
41
|
+
function record(condition, message) {
|
|
42
|
+
if (!condition) failures.push(message);
|
|
43
|
+
else checks.push(message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function read(relativePath) {
|
|
47
|
+
return readFileSync(resolve(pluginRoot, relativePath), "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function classify(kind, content, base = pluginRoot) {
|
|
51
|
+
const errors = [];
|
|
52
|
+
if (kind === "release-text") {
|
|
53
|
+
if (/current[^\n]{0,80}(?:alpha\.2|0\.1\.0-alpha\.2)|install[^\n]{0,80}v0\.1\.0-alpha\.2/i.test(content)) errors.push("stale-release");
|
|
54
|
+
}
|
|
55
|
+
if (kind === "markdown-link") {
|
|
56
|
+
for (const match of content.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)) {
|
|
57
|
+
const target = match[1];
|
|
58
|
+
if (/^(?:https?:\/\/|mailto:|#)/.test(target)) continue;
|
|
59
|
+
const path = decodeURIComponent(target.split("#")[0]);
|
|
60
|
+
if (!path) continue;
|
|
61
|
+
const resolved = resolve(base, path);
|
|
62
|
+
if (!resolved.startsWith(`${pluginRoot}${sep}`) || !existsSync(resolved)) errors.push("broken-link");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (kind === "issue-form") {
|
|
66
|
+
if (!/^name:\s*\S.+$/m.test(content) || !/^description:\s*\S.+$/m.test(content)) errors.push("invalid-issue-metadata");
|
|
67
|
+
if (/current alpha\.3 documentation|example v0\.1\.0-alpha\.3/i.test(content)) errors.push("stale-issue-version");
|
|
68
|
+
}
|
|
69
|
+
if (kind === "public-text") {
|
|
70
|
+
if (/\/Users\/|\.codex\/worktrees\/|[A-Za-z]:\\Users\\/.test(content)) errors.push("private-path");
|
|
71
|
+
if (/openspec\/changes\/|canonical private source workspace|active change[^\n]{0,80}tasks\.md|inventory\/[^\s`)]+-\d{8}\.md/i.test(content)) {
|
|
72
|
+
errors.push("private-process-reference");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (kind === "no-node-claim") {
|
|
76
|
+
const lines = content.split(/\r?\n/);
|
|
77
|
+
const requiresProjectNode = lines.some((line) => {
|
|
78
|
+
if (!/(?:project|workspace)[^\n]{0,80}(?:requires?|must have|needs?)[^\n]{0,40}Node\.js/i.test(line)) return false;
|
|
79
|
+
return !/(?:does not|doesn't|do not|don't|no project-local|不要求|不需要|无需)/i.test(line);
|
|
80
|
+
});
|
|
81
|
+
if (/(?:before|to) (?:use|using|install)[^\n]{0,100}(?:initialize|create|add)[^\n]{0,40}package\.json/i.test(content) || requiresProjectNode) errors.push("project-node-required");
|
|
82
|
+
}
|
|
83
|
+
return [...new Set(errors)].sort();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const path of requiredFiles) record(existsSync(resolve(pluginRoot, path)), `required public file exists: ${path}`);
|
|
87
|
+
|
|
88
|
+
for (const path of entryFiles) {
|
|
89
|
+
const content = read(path);
|
|
90
|
+
record(content.includes("0.1.0-alpha.4") && content.includes("v0.1.0-alpha.4"), `released alpha.4 identity is explicit: ${path}`);
|
|
91
|
+
record(!classify("release-text", content).includes("stale-release"), `no stale alpha.2 current-install claim: ${path}`);
|
|
92
|
+
record(classify("public-text", content).length === 0, `no private path: ${path}`);
|
|
93
|
+
record(classify("markdown-link", content, dirname(resolve(pluginRoot, path))).length === 0, `relative Markdown links resolve: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const path of ["README.md", "README.zh-CN.md", "docs/installation.md", "docs/installation.zh-CN.md"]) {
|
|
97
|
+
const content = read(path);
|
|
98
|
+
record(content.includes(exactInstall), `exact alpha.4 Marketplace command is present: ${path}`);
|
|
99
|
+
for (const skill of skills) record(content.includes(skill), `${path} covers ${skill}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const path of ["README.md", "README.zh-CN.md", "docs/installation.md", "docs/installation.zh-CN.md"]) {
|
|
103
|
+
const content = read(path);
|
|
104
|
+
record(/does not (?:require|need)|不要求|不需要/.test(content) && content.includes("package.json") && content.includes("node_modules") && content.includes(".forgerail/"), `Plugin Only no-project-Node boundary is explicit: ${path}`);
|
|
105
|
+
record(classify("no-node-claim", content).length === 0, `no accidental project Node requirement: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const path of publicTextFiles) {
|
|
109
|
+
const content = read(path);
|
|
110
|
+
record(classify("public-text", content).length === 0, `public text has no private path or process reference: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const path of issueForms) {
|
|
114
|
+
record(classify("issue-form", read(path)).length === 0, `issue form has current version, name, and description: ${path}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fixtures = JSON.parse(read("scripts/fixtures/open-source-docs/cases.json"));
|
|
118
|
+
record(fixtures.schemaVersion === "1.0" && Array.isArray(fixtures.cases), "documentation fixture schema is valid");
|
|
119
|
+
for (const fixture of fixtures.cases) {
|
|
120
|
+
const base = fixture.kind === "markdown-link" ? resolve(pluginRoot, "scripts/fixtures/open-source-docs") : pluginRoot;
|
|
121
|
+
const content = fixture.content.replace("__PRIVATE_USER_PATH__", ["", "Users", "example", ".codex", "worktrees", "private", "project"].join("/"));
|
|
122
|
+
const actual = classify(fixture.kind, content, base);
|
|
123
|
+
const expected = [...fixture.expectedErrors].sort();
|
|
124
|
+
record(JSON.stringify(actual) === JSON.stringify(expected), `fixture fails closed as expected: ${fixture.id}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (failures.length > 0) {
|
|
128
|
+
console.error(JSON.stringify({ status: "failed", checks: checks.length, failures }, null, 2));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(JSON.stringify({ status: "passed", checks: checks.length, fixtures: fixtures.cases.length }, null, 2));
|