miaoda-game-devkit 0.7.1 → 0.7.2
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.
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://miaoda.dev/schemas/game-mechanics-capabilities.schema.json",
|
|
4
|
+
"title": "Miaoda game-mechanics capability boundaries",
|
|
5
|
+
"description": "Repository-owned routing metadata used to help tools and AI select packages without assigning duplicate runtime ownership.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["$schema", "schemaVersion", "requiredPackages", "packages"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": {
|
|
11
|
+
"const": "./game-mechanics-capabilities.schema.json",
|
|
12
|
+
"description": "Local schema reference for editor validation and field documentation."
|
|
13
|
+
},
|
|
14
|
+
"schemaVersion": {
|
|
15
|
+
"const": 1,
|
|
16
|
+
"description": "Version of this machine-readable capability document."
|
|
17
|
+
},
|
|
18
|
+
"requiredPackages": {
|
|
19
|
+
"description": "Public packages that must have capability metadata; repository validation rejects missing entries.",
|
|
20
|
+
"type": "array",
|
|
21
|
+
"minItems": 1,
|
|
22
|
+
"uniqueItems": true,
|
|
23
|
+
"items": { "$ref": "#/$defs/packageName" }
|
|
24
|
+
},
|
|
25
|
+
"packages": {
|
|
26
|
+
"description": "Capability metadata keyed by full published package name.",
|
|
27
|
+
"type": "object",
|
|
28
|
+
"minProperties": 1,
|
|
29
|
+
"propertyNames": { "$ref": "#/$defs/packageName" },
|
|
30
|
+
"additionalProperties": { "$ref": "#/$defs/capability" }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"$defs": {
|
|
34
|
+
"packageName": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"pattern": "^miaoda-game-[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
37
|
+
},
|
|
38
|
+
"tokenList": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"uniqueItems": true,
|
|
42
|
+
"items": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"packageList": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"uniqueItems": true,
|
|
50
|
+
"items": { "$ref": "#/$defs/packageName" }
|
|
51
|
+
},
|
|
52
|
+
"alternative": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"additionalProperties": false,
|
|
55
|
+
"required": ["package", "condition"],
|
|
56
|
+
"properties": {
|
|
57
|
+
"package": {
|
|
58
|
+
"$ref": "#/$defs/packageName",
|
|
59
|
+
"description": "Package that should be selected instead when the condition applies."
|
|
60
|
+
},
|
|
61
|
+
"condition": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1,
|
|
64
|
+
"description": "Human-readable decision condition for AI and maintainers; it is not executable game logic."
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"capability": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"additionalProperties": false,
|
|
71
|
+
"required": [
|
|
72
|
+
"engine",
|
|
73
|
+
"domains",
|
|
74
|
+
"owns",
|
|
75
|
+
"doesNotOwn",
|
|
76
|
+
"compatibleWith",
|
|
77
|
+
"useInsteadWhen",
|
|
78
|
+
"persistence"
|
|
79
|
+
],
|
|
80
|
+
"properties": {
|
|
81
|
+
"engine": {
|
|
82
|
+
"enum": ["neutral", "react", "phaser", "cocos"],
|
|
83
|
+
"description": "Runtime boundary: neutral logic or a specific host integration."
|
|
84
|
+
},
|
|
85
|
+
"domains": {
|
|
86
|
+
"$ref": "#/$defs/tokenList",
|
|
87
|
+
"description": "Broad routing tags used to discover relevant packages."
|
|
88
|
+
},
|
|
89
|
+
"owns": {
|
|
90
|
+
"$ref": "#/$defs/tokenList",
|
|
91
|
+
"description": "Responsibilities for which this package is authoritative."
|
|
92
|
+
},
|
|
93
|
+
"doesNotOwn": {
|
|
94
|
+
"$ref": "#/$defs/tokenList",
|
|
95
|
+
"description": "Common adjacent responsibilities that must remain elsewhere."
|
|
96
|
+
},
|
|
97
|
+
"compatibleWith": {
|
|
98
|
+
"$ref": "#/$defs/packageList",
|
|
99
|
+
"description": "Public packages that form an intentional non-overlapping composition."
|
|
100
|
+
},
|
|
101
|
+
"useInsteadWhen": {
|
|
102
|
+
"type": "array",
|
|
103
|
+
"items": { "$ref": "#/$defs/alternative" },
|
|
104
|
+
"description": "Explicit alternatives that prevent plausible but incorrect package selection."
|
|
105
|
+
},
|
|
106
|
+
"persistence": {
|
|
107
|
+
"enum": ["none", "snapshot", "envelope", "delegated"],
|
|
108
|
+
"description": "Persistence responsibility: none, package snapshot, versioned envelope, or delegated to another owner."
|
|
109
|
+
},
|
|
110
|
+
"testability": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"additionalProperties": false,
|
|
113
|
+
"required": ["observation", "advance", "methods"],
|
|
114
|
+
"description": "Optional deterministic headless boundary for consumers and AI-authored playthroughs.",
|
|
115
|
+
"properties": {
|
|
116
|
+
"observation": {
|
|
117
|
+
"enum": [
|
|
118
|
+
"return-value",
|
|
119
|
+
"command-result",
|
|
120
|
+
"snapshot",
|
|
121
|
+
"step-result",
|
|
122
|
+
"snapshot-and-result",
|
|
123
|
+
"snapshot-and-step-result",
|
|
124
|
+
"axis-and-actions"
|
|
125
|
+
],
|
|
126
|
+
"description": "Smallest stable readback shape exposed by the package."
|
|
127
|
+
},
|
|
128
|
+
"advance": {
|
|
129
|
+
"enum": ["none", "command", "update", "step", "scheduler", "manual"],
|
|
130
|
+
"description": "How a consumer explicitly advances or samples the package."
|
|
131
|
+
},
|
|
132
|
+
"methods": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"additionalProperties": false,
|
|
135
|
+
"required": ["observe", "advance"],
|
|
136
|
+
"properties": {
|
|
137
|
+
"observe": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"minLength": 1,
|
|
140
|
+
"description": "Public observation method or expression."
|
|
141
|
+
},
|
|
142
|
+
"advance": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"minLength": 1,
|
|
145
|
+
"description": "Public advancement or sampling method."
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
const guardSourceId = 'miaoda-game-mechanics-guard';
|
|
2
|
+
const { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } = require('node:fs');
|
|
3
|
+
const { join, resolve } = require('node:path');
|
|
4
|
+
|
|
5
|
+
const projectRoot = resolve(process.env.MIAODA_GAME_PROJECT_ROOT || process.cwd());
|
|
6
|
+
const capabilityPath = resolve(
|
|
7
|
+
process.env.MIAODA_GAME_CAPABILITIES || join(__dirname, 'game-mechanics-capabilities.json'),
|
|
8
|
+
);
|
|
9
|
+
const packageJsonPath = resolve(
|
|
10
|
+
process.env.MIAODA_GAME_PACKAGE_JSON || join(projectRoot, 'package.json'),
|
|
11
|
+
);
|
|
12
|
+
const contextPath = resolve(
|
|
13
|
+
process.env.MIAODA_GAME_CONTEXT || join(projectRoot, 'miaoda-game-context.json'),
|
|
14
|
+
);
|
|
15
|
+
const localPnpmfilePath = resolve(
|
|
16
|
+
process.env.MIAODA_GAME_LOCAL_PNPMFILE || join(projectRoot, '.pnpmfile.local.cjs'),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const dependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
20
|
+
const excludedPackageNames = new Set(['miaoda-game-devkit']);
|
|
21
|
+
const packageGuidance =
|
|
22
|
+
'Prefer installed miaoda-game-* packages for matching game mechanics and core gameplay logic.\n' +
|
|
23
|
+
'Do not reimplement equivalent physics rules, AI, pathfinding, simulation, or algorithms in application code.\n' +
|
|
24
|
+
'Keep local code focused on game-specific composition, presentation, and thin integration.';
|
|
25
|
+
const contextReminder =
|
|
26
|
+
'Read miaoda-game-context.json before implementing gameplay. Use the installed packages and respect their owns and doesNotOwn boundaries.';
|
|
27
|
+
const automaticContextInstructions = [
|
|
28
|
+
'Read this file before implementing or changing gameplay mechanics.',
|
|
29
|
+
'Prefer a directly installed Miaoda package when its owns field matches the required mechanic.',
|
|
30
|
+
'Do not reimplement responsibilities listed in a package owns field.',
|
|
31
|
+
'Respect doesNotOwn and keep those responsibilities outside that package.',
|
|
32
|
+
'Read the installed package README and public types before using its API.',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function readJson(path) {
|
|
36
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function projectConfig(projectManifest) {
|
|
40
|
+
return projectManifest?.miaodaGame;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isMechanicPackage(name) {
|
|
44
|
+
return (
|
|
45
|
+
typeof name === 'string' &&
|
|
46
|
+
name.startsWith('miaoda-game-') &&
|
|
47
|
+
!excludedPackageNames.has(name)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateProjectConfig(capabilityDocument, projectManifest) {
|
|
52
|
+
const capabilities = capabilityDocument?.packages || {};
|
|
53
|
+
const config = projectConfig(projectManifest);
|
|
54
|
+
const engines = new Set(['neutral', 'react', 'phaser', 'cocos']);
|
|
55
|
+
const problems = [];
|
|
56
|
+
|
|
57
|
+
if (config === undefined) return [];
|
|
58
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
59
|
+
return ['package.json miaodaGame must be an object when defined'];
|
|
60
|
+
}
|
|
61
|
+
if (config.schemaVersion !== 1) problems.push('miaodaGame.schemaVersion must be 1');
|
|
62
|
+
if (!engines.has(config.engine)) {
|
|
63
|
+
problems.push('miaodaGame.engine must be neutral, react, phaser, or cocos');
|
|
64
|
+
}
|
|
65
|
+
if (!config.uses || typeof config.uses !== 'object' || Array.isArray(config.uses)) {
|
|
66
|
+
problems.push('miaodaGame.uses must be an object');
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(config.blockedPackages)) {
|
|
69
|
+
problems.push('miaodaGame.blockedPackages must be an array');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [packageName, uses] of Object.entries(config.uses || {})) {
|
|
73
|
+
const capability = capabilities[packageName];
|
|
74
|
+
if (!capability) {
|
|
75
|
+
problems.push(`${packageName}: no capability metadata is available`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(uses) || uses.length === 0) {
|
|
79
|
+
problems.push(`${packageName}: declare at least one intended owned capability`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (new Set(uses).size !== uses.length) {
|
|
83
|
+
problems.push(`${packageName}: miaodaGame.uses contains duplicate capabilities`);
|
|
84
|
+
}
|
|
85
|
+
for (const use of uses) {
|
|
86
|
+
if (!capability.owns.includes(use)) {
|
|
87
|
+
problems.push(
|
|
88
|
+
`${packageName}: '${use}' is not owned by this package; owned capabilities: ${capability.owns.join(', ')}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
capability.engine !== 'neutral' &&
|
|
94
|
+
config.engine !== 'neutral' &&
|
|
95
|
+
capability.engine !== config.engine
|
|
96
|
+
) {
|
|
97
|
+
problems.push(
|
|
98
|
+
`${packageName}: ${capability.engine} package is incompatible with ${config.engine} project`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return problems;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createGuard(capabilityDocument, projectManifest, observedPackages = new Map()) {
|
|
107
|
+
const capabilities = capabilityDocument?.packages || {};
|
|
108
|
+
const config = projectConfig(projectManifest);
|
|
109
|
+
const problems = validateProjectConfig(capabilityDocument, projectManifest);
|
|
110
|
+
if (problems.length) {
|
|
111
|
+
throw new Error(`Invalid package.json miaodaGame configuration:\n- ${problems.join('\n- ')}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const blockedPackages = new Set(config?.blockedPackages || []);
|
|
115
|
+
let guidanceLogged = false;
|
|
116
|
+
const loggedPackages = new Set();
|
|
117
|
+
|
|
118
|
+
return function readPackage(pkg, context) {
|
|
119
|
+
if (!isMechanicPackage(pkg?.name)) return pkg;
|
|
120
|
+
observedPackages.set(pkg.name, {
|
|
121
|
+
version: pkg.version,
|
|
122
|
+
description: pkg.description,
|
|
123
|
+
types: pkg.types,
|
|
124
|
+
});
|
|
125
|
+
if (blockedPackages.has(pkg.name)) {
|
|
126
|
+
throw new Error(`Restricted by package.json miaodaGame.blockedPackages: ${pkg.name}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!guidanceLogged) {
|
|
130
|
+
context.log(packageGuidance);
|
|
131
|
+
guidanceLogged = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const capability = capabilities[pkg.name];
|
|
135
|
+
if (!capability) return pkg;
|
|
136
|
+
if (
|
|
137
|
+
config &&
|
|
138
|
+
capability.engine !== 'neutral' &&
|
|
139
|
+
config.engine !== 'neutral' &&
|
|
140
|
+
capability.engine !== config.engine
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Restricted: ${pkg.name} targets ${capability.engine}, but this project targets ${config.engine}.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const uses = config?.uses?.[pkg.name];
|
|
148
|
+
if (uses && !loggedPackages.has(pkg.name)) {
|
|
149
|
+
context.log(
|
|
150
|
+
`[miaoda-game] ${pkg.name}: approved for ${uses.join(', ')}; does not own ${capability.doesNotOwn.join(', ')}`,
|
|
151
|
+
);
|
|
152
|
+
loggedPackages.add(pkg.name);
|
|
153
|
+
}
|
|
154
|
+
return pkg;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function directDependencies(lockfile, projectManifest) {
|
|
159
|
+
const importer = lockfile?.importers?.['.'];
|
|
160
|
+
const result = new Map();
|
|
161
|
+
for (const field of dependencyFields) {
|
|
162
|
+
const resolved = importer?.[field] || {};
|
|
163
|
+
const declared = projectManifest?.[field] || {};
|
|
164
|
+
for (const name of new Set([...Object.keys(declared), ...Object.keys(resolved)])) {
|
|
165
|
+
if (!isMechanicPackage(name)) continue;
|
|
166
|
+
result.set(name, { dependencyType: field, resolution: resolved[name] || declared[name] });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolvedVersion(resolution, observed) {
|
|
173
|
+
if (typeof observed?.version === 'string') return observed.version;
|
|
174
|
+
const value = typeof resolution === 'string' ? resolution : resolution?.version;
|
|
175
|
+
if (typeof value !== 'string') return 'unknown';
|
|
176
|
+
if (value.startsWith('link:') || value.startsWith('workspace:')) return value;
|
|
177
|
+
return value.split('(')[0];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createContext(
|
|
181
|
+
capabilityDocument,
|
|
182
|
+
projectManifest,
|
|
183
|
+
lockfile,
|
|
184
|
+
observedPackages = new Map(),
|
|
185
|
+
) {
|
|
186
|
+
const config = projectConfig(projectManifest);
|
|
187
|
+
const direct = directDependencies(lockfile, projectManifest);
|
|
188
|
+
const directNames = new Set(direct.keys());
|
|
189
|
+
const configuredUses = config?.uses || {};
|
|
190
|
+
const blockedPackages = config?.blockedPackages || [];
|
|
191
|
+
const packages = {};
|
|
192
|
+
const assignments = {};
|
|
193
|
+
const problems = [];
|
|
194
|
+
|
|
195
|
+
for (const name of [...direct.keys()].sort()) {
|
|
196
|
+
const { dependencyType, resolution } = direct.get(name);
|
|
197
|
+
const capability = capabilityDocument.packages[name];
|
|
198
|
+
const observed = observedPackages.get(name);
|
|
199
|
+
const entry = {
|
|
200
|
+
version: resolvedVersion(resolution, observed),
|
|
201
|
+
dependencyType,
|
|
202
|
+
...(observed?.description ? { description: observed.description } : {}),
|
|
203
|
+
};
|
|
204
|
+
if (capability) {
|
|
205
|
+
Object.assign(entry, {
|
|
206
|
+
engine: capability.engine,
|
|
207
|
+
domains: capability.domains,
|
|
208
|
+
owns: capability.owns,
|
|
209
|
+
doesNotOwn: capability.doesNotOwn,
|
|
210
|
+
compatibleWith: capability.compatibleWith,
|
|
211
|
+
installedCompanions: capability.compatibleWith.filter((item) => directNames.has(item)),
|
|
212
|
+
useInsteadWhen: capability.useInsteadWhen,
|
|
213
|
+
persistence: capability.persistence,
|
|
214
|
+
...(capability.testability ? { testability: capability.testability } : {}),
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
entry.capabilityStatus = 'missing';
|
|
218
|
+
problems.push({
|
|
219
|
+
code: 'CAPABILITY_METADATA_MISSING',
|
|
220
|
+
package: name,
|
|
221
|
+
message: `${name} is installed directly but has no capability metadata. Read its README and public types before assigning ownership.`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
entry.references = {
|
|
225
|
+
packageJson: `node_modules/${name}/package.json`,
|
|
226
|
+
readme: `node_modules/${name}/README.md`,
|
|
227
|
+
...(observed?.types ? { types: `node_modules/${name}/${observed.types}` } : {}),
|
|
228
|
+
importFrom: name,
|
|
229
|
+
};
|
|
230
|
+
packages[name] = entry;
|
|
231
|
+
|
|
232
|
+
if (!config) continue;
|
|
233
|
+
const uses = configuredUses[name];
|
|
234
|
+
if (!uses) {
|
|
235
|
+
problems.push({
|
|
236
|
+
code: 'INTENDED_USE_MISSING',
|
|
237
|
+
package: name,
|
|
238
|
+
message: `${name} is installed directly but package.json miaodaGame.uses does not assign its project responsibilities.`,
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const capabilityName of [...uses].sort()) {
|
|
243
|
+
const existing = assignments[capabilityName];
|
|
244
|
+
if (existing && existing.owner !== name) {
|
|
245
|
+
problems.push({
|
|
246
|
+
code: 'DUPLICATE_OWNER',
|
|
247
|
+
capability: capabilityName,
|
|
248
|
+
packages: [existing.owner, name].sort(),
|
|
249
|
+
message: `${capabilityName} is assigned to more than one directly installed package.`,
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
assignments[capabilityName] = { owner: name, required: true };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const name of Object.keys(configuredUses).sort()) {
|
|
258
|
+
if (!directNames.has(name)) {
|
|
259
|
+
problems.push({
|
|
260
|
+
code: 'SELECTED_PACKAGE_NOT_INSTALLED',
|
|
261
|
+
package: name,
|
|
262
|
+
message: `${name} is configured in package.json miaodaGame.uses but is not a direct dependency.`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const name of [...blockedPackages].sort()) {
|
|
267
|
+
if (directNames.has(name)) {
|
|
268
|
+
problems.push({
|
|
269
|
+
code: 'BLOCKED_PACKAGE_INSTALLED',
|
|
270
|
+
package: name,
|
|
271
|
+
message: `${name} is both directly installed and blocked by package.json miaodaGame.blockedPackages.`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
schemaVersion: 1,
|
|
278
|
+
generated: true,
|
|
279
|
+
instructions: config
|
|
280
|
+
? [
|
|
281
|
+
automaticContextInstructions[0],
|
|
282
|
+
'Use the assigned Miaoda package for every responsibility in assignments.',
|
|
283
|
+
...automaticContextInstructions.slice(2),
|
|
284
|
+
]
|
|
285
|
+
: automaticContextInstructions,
|
|
286
|
+
project: config
|
|
287
|
+
? { selectionMode: 'declared', engine: config.engine }
|
|
288
|
+
: { selectionMode: 'installed-direct-dependencies' },
|
|
289
|
+
packages,
|
|
290
|
+
assignments: Object.fromEntries(
|
|
291
|
+
Object.entries(assignments).sort(([left], [right]) => left.localeCompare(right)),
|
|
292
|
+
),
|
|
293
|
+
problems,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function writeContext(path, context) {
|
|
298
|
+
const source = `${JSON.stringify(context, null, 2)}\n`;
|
|
299
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === source) return false;
|
|
300
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
301
|
+
try {
|
|
302
|
+
writeFileSync(temporaryPath, source);
|
|
303
|
+
renameSync(temporaryPath, path);
|
|
304
|
+
} finally {
|
|
305
|
+
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function rootImporterLockfile(path) {
|
|
311
|
+
if (!existsSync(path)) return undefined;
|
|
312
|
+
const importer = {};
|
|
313
|
+
let inRootImporter = false;
|
|
314
|
+
let dependencyField;
|
|
315
|
+
let packageName;
|
|
316
|
+
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
|
317
|
+
if (line === ' .:') {
|
|
318
|
+
inRootImporter = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!inRootImporter) continue;
|
|
322
|
+
if (/^ \S/.test(line)) break;
|
|
323
|
+
const fieldMatch = line.match(/^ (dependencies|devDependencies|optionalDependencies):$/);
|
|
324
|
+
if (fieldMatch) {
|
|
325
|
+
dependencyField = fieldMatch[1];
|
|
326
|
+
importer[dependencyField] = {};
|
|
327
|
+
packageName = undefined;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const packageMatch = dependencyField && line.match(/^ (miaoda-game-[a-z0-9-]+):$/);
|
|
331
|
+
if (packageMatch) {
|
|
332
|
+
packageName = packageMatch[1];
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const versionMatch = packageName && line.match(/^ version:\s+(.+)$/);
|
|
336
|
+
if (versionMatch) {
|
|
337
|
+
importer[dependencyField][packageName] = {
|
|
338
|
+
version: versionMatch[1].replace(/^['"]|['"]$/g, ''),
|
|
339
|
+
};
|
|
340
|
+
packageName = undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { importers: { '.': importer } };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function installedDirectPackages(projectManifest) {
|
|
347
|
+
const observed = new Map();
|
|
348
|
+
for (const name of directDependencies(undefined, projectManifest).keys()) {
|
|
349
|
+
const declared = dependencyFields
|
|
350
|
+
.map((field) => projectManifest?.[field]?.[name])
|
|
351
|
+
.find((value) => typeof value === 'string');
|
|
352
|
+
const localPath = declared?.startsWith('file:')
|
|
353
|
+
? resolve(projectRoot, declared.slice('file:'.length), 'package.json')
|
|
354
|
+
: undefined;
|
|
355
|
+
const installedManifestPath = existsSync(join(projectRoot, 'node_modules', name, 'package.json'))
|
|
356
|
+
? join(projectRoot, 'node_modules', name, 'package.json')
|
|
357
|
+
: localPath;
|
|
358
|
+
if (!installedManifestPath || !existsSync(installedManifestPath)) continue;
|
|
359
|
+
const installedManifest = readJson(installedManifestPath);
|
|
360
|
+
observed.set(name, {
|
|
361
|
+
version: installedManifest.version,
|
|
362
|
+
description: installedManifest.description,
|
|
363
|
+
types: installedManifest.types,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return observed;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function refreshInstalledContext(capabilityDocument, projectManifest, path) {
|
|
370
|
+
const observed = installedDirectPackages(projectManifest);
|
|
371
|
+
const lockfile = rootImporterLockfile(join(projectRoot, 'pnpm-lock.yaml'));
|
|
372
|
+
const direct = directDependencies(lockfile, projectManifest);
|
|
373
|
+
if (
|
|
374
|
+
[...direct.keys()].some(
|
|
375
|
+
(name) => resolvedVersion(direct.get(name).resolution, observed.get(name)) === 'unknown',
|
|
376
|
+
)
|
|
377
|
+
) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
return writeContext(path, createContext(capabilityDocument, projectManifest, lockfile, observed));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!existsSync(packageJsonPath)) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Missing ${packageJsonPath}. The pnpm guard must be installed beside package.json.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const capabilityDocument = readJson(capabilityPath);
|
|
390
|
+
const projectManifest = readJson(packageJsonPath);
|
|
391
|
+
const observedPackages = new Map();
|
|
392
|
+
const guardReadPackage = createGuard(capabilityDocument, projectManifest, observedPackages);
|
|
393
|
+
const localPnpmfile = existsSync(localPnpmfilePath) ? require(localPnpmfilePath) : {};
|
|
394
|
+
let contextReminderLogged = false;
|
|
395
|
+
|
|
396
|
+
if (process.env.MIAODA_GAME_CONTEXT_DISABLED !== '1') {
|
|
397
|
+
refreshInstalledContext(capabilityDocument, projectManifest, contextPath);
|
|
398
|
+
const installedLockfile = rootImporterLockfile(join(projectRoot, 'pnpm-lock.yaml'));
|
|
399
|
+
if (existsSync(contextPath) && directDependencies(installedLockfile, projectManifest).size > 0) {
|
|
400
|
+
console.log(contextReminder);
|
|
401
|
+
contextReminderLogged = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function logContextReminder(context) {
|
|
406
|
+
if (contextReminderLogged) return;
|
|
407
|
+
if (typeof context?.log === 'function') context.log(contextReminder);
|
|
408
|
+
else console.log(contextReminder);
|
|
409
|
+
contextReminderLogged = true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function readPackage(pkg, context) {
|
|
413
|
+
const guarded = guardReadPackage(pkg, context);
|
|
414
|
+
const directNames = directDependencies(undefined, projectManifest);
|
|
415
|
+
if (
|
|
416
|
+
directNames.has(pkg?.name) &&
|
|
417
|
+
[...directNames.keys()].every((name) => observedPackages.has(name))
|
|
418
|
+
) {
|
|
419
|
+
writeContext(
|
|
420
|
+
contextPath,
|
|
421
|
+
createContext(capabilityDocument, projectManifest, undefined, observedPackages),
|
|
422
|
+
);
|
|
423
|
+
logContextReminder(context);
|
|
424
|
+
}
|
|
425
|
+
const localReadPackage = localPnpmfile.hooks?.readPackage;
|
|
426
|
+
return typeof localReadPackage === 'function' ? localReadPackage(guarded, context) : guarded;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function afterAllResolved(lockfile, context) {
|
|
430
|
+
const localAfterAllResolved = localPnpmfile.hooks?.afterAllResolved;
|
|
431
|
+
const resolved =
|
|
432
|
+
typeof localAfterAllResolved === 'function'
|
|
433
|
+
? (await localAfterAllResolved(lockfile, context)) || lockfile
|
|
434
|
+
: lockfile;
|
|
435
|
+
const generated = createContext(capabilityDocument, projectManifest, resolved, observedPackages);
|
|
436
|
+
const changed = writeContext(contextPath, generated);
|
|
437
|
+
if (changed && typeof context?.log === 'function') {
|
|
438
|
+
context.log(`[miaoda-game] Generated ${contextPath}.`);
|
|
439
|
+
}
|
|
440
|
+
if (directDependencies(resolved, projectManifest).size > 0) logContextReminder(context);
|
|
441
|
+
return resolved;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
module.exports = {
|
|
445
|
+
...localPnpmfile,
|
|
446
|
+
guardSourceId,
|
|
447
|
+
hooks: {
|
|
448
|
+
...localPnpmfile.hooks,
|
|
449
|
+
readPackage,
|
|
450
|
+
afterAllResolved,
|
|
451
|
+
},
|
|
452
|
+
createContext,
|
|
453
|
+
createGuard,
|
|
454
|
+
directDependencies,
|
|
455
|
+
isMechanicPackage,
|
|
456
|
+
refreshInstalledContext,
|
|
457
|
+
rootImporterLockfile,
|
|
458
|
+
validateProjectConfig,
|
|
459
|
+
writeContext,
|
|
460
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miaoda-game-devkit",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -73,12 +73,14 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"./react/vitest-setup": "./dist/react/vitest-setup.mjs",
|
|
76
|
+
"./pnpmfile": "./mechanics/pnpmfile.cjs",
|
|
76
77
|
"./biome": "./biome-config.json",
|
|
77
78
|
"./tsconfig-base": "./tsconfig-base.json"
|
|
78
79
|
},
|
|
79
80
|
"files": [
|
|
80
81
|
"dist",
|
|
81
82
|
"bin",
|
|
83
|
+
"mechanics",
|
|
82
84
|
"biome-config.json",
|
|
83
85
|
"oxlint-config.json",
|
|
84
86
|
"tsconfig-base.json",
|