@givolt/i3-composer-agent 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude/commands/i3-prototype.md +5 -7
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.gemini/commands/i3-prototype.toml +5 -7
  7. package/README.md +16 -7
  8. package/bin/agent-targets.json +4 -2
  9. package/bin/i3-composer-agent.cjs +74 -7
  10. package/bin/install.cjs +16 -1
  11. package/bin/native-layout.cjs +1 -1
  12. package/bin/native-migration.cjs +27 -3
  13. package/bin/tool-selector.cjs +24 -7
  14. package/bin/wizard.cjs +89 -15
  15. package/commands/i3-prototype.toml +5 -7
  16. package/docs/codebuddy-setup.md +91 -3
  17. package/docs/codex-setup.md +6 -0
  18. package/docs/other-hosts.md +1 -1
  19. package/docs/ui-prototype-native/MAIN-SESSION-REPORT.md +66 -0
  20. package/docs/ui-prototype-native/USAGE.md +93 -0
  21. package/docs/workbuddy-setup.md +6 -0
  22. package/evals/cases/i3-frontend-ui-engineering.json +16 -0
  23. package/evals/cases/i3-ui-prototype.json +109 -0
  24. package/evals/cases/i3-using-agent-skills.json +28 -0
  25. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.css +3 -0
  26. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.js +8 -0
  27. package/evals/fixtures/i3-ui-prototype/admin-list/capability-definition.json +67 -0
  28. package/evals/fixtures/i3-ui-prototype/admin-list/index.html +89 -0
  29. package/evals/fixtures/i3-ui-prototype/admin-list/readme.md +52 -0
  30. package/evals/fixtures/i3-ui-prototype/admin-list/template.json +8 -0
  31. package/evals/zh-CN/cases/i3-frontend-ui-engineering.json +17 -0
  32. package/evals/zh-CN/cases/i3-ui-prototype.json +115 -0
  33. package/evals/zh-CN/cases/i3-using-agent-skills.json +30 -0
  34. package/hooks/codebuddy-upstream.cjs +93 -0
  35. package/hooks/i3-prototype.cjs +12 -0
  36. package/hooks/manage.cjs +162 -0
  37. package/package.json +61 -1
  38. package/plugin.json +1 -1
  39. package/scripts/design-provider-test.js +7 -0
  40. package/scripts/enterprise-template-test.js +27 -1
  41. package/scripts/lib/prototype-fixture.cjs +17 -0
  42. package/scripts/prototype-cache-test.js +55 -0
  43. package/scripts/prototype-generation-entry-test.js +6 -0
  44. package/scripts/prototype-generation-evidence-test.js +10 -0
  45. package/scripts/prototype-generation-native-test.js +78 -0
  46. package/scripts/prototype-generation-test.js +99 -0
  47. package/scripts/prototype-inventory-test.js +43 -0
  48. package/scripts/prototype-locations-test.js +79 -0
  49. package/scripts/prototype-project-test.js +183 -0
  50. package/scripts/ui-prototype-codebuddy-hook-test.js +106 -0
  51. package/scripts/ui-prototype-content-test.js +123 -0
  52. package/scripts/ui-prototype-dependencies-test.js +213 -0
  53. package/scripts/ui-prototype-devtools-test.js +53 -0
  54. package/scripts/ui-prototype-execution-evidence-test.js +62 -0
  55. package/scripts/ui-prototype-execution-gates-test.js +66 -0
  56. package/scripts/ui-prototype-execution-policy-test.js +42 -0
  57. package/scripts/ui-prototype-execution-runtime-test.js +27 -0
  58. package/scripts/ui-prototype-ide-source-test.js +73 -0
  59. package/scripts/ui-prototype-native-test.js +504 -0
  60. package/scripts/ui-prototype-registration-test.js +53 -0
  61. package/scripts/ui-prototype-template-test.js +261 -0
  62. package/scripts/ui-prototype-test.js +10 -0
  63. package/scripts/ui-prototype-transcript-auto-test.js +67 -0
  64. package/skills/i3-frontend-ui-engineering/SKILL.md +1 -1
  65. package/skills/i3-incremental-implementation/SKILL.md +1 -1
  66. package/skills/i3-ui-prototype/SKILL.md +158 -19
  67. package/skills/i3-ui-prototype/assets/capabilities/generation.json +833 -0
  68. package/skills/i3-ui-prototype/assets/capabilities/management.json +294 -0
  69. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/acceptance.json +51 -0
  70. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/component-map.json +13 -0
  71. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/generation-component-map.json +300 -0
  72. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/index.html +11 -0
  73. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package-lock.json +2298 -0
  74. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package.json +22 -0
  75. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/public/prototype-config.json +6 -0
  76. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/App.vue +136 -0
  77. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/components/DeviceEditor.vue +111 -0
  78. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/config.ts +19 -0
  79. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/content.ts +28 -0
  80. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts +50 -0
  81. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts +25 -0
  82. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/main.ts +18 -0
  83. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/style.css +50 -0
  84. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts +66 -0
  85. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts +44 -0
  86. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts +26 -0
  87. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tsconfig.json +15 -0
  88. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/vite.config.ts +4 -0
  89. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/profile.json +174 -0
  90. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/readme.md +61 -0
  91. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/index.html +11 -0
  92. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package-lock.json +2298 -0
  93. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package.json +22 -0
  94. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/public/prototype-config.json +21 -0
  95. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +62 -0
  96. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/PrototypeShell.vue +59 -0
  97. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +23 -0
  98. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/presentation.ts +29 -0
  99. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/theme.ts +25 -0
  100. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/main.ts +18 -0
  101. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +26 -0
  102. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/presentation.test.ts +23 -0
  103. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/theme.test.ts +26 -0
  104. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tsconfig.json +15 -0
  105. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/vite.config.ts +4 -0
  106. package/skills/i3-ui-prototype/assets/hosts/codebuddy/profile.json +13 -0
  107. package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +739 -0
  108. package/skills/i3-ui-prototype/assets/optional-skills/sources.json +27 -0
  109. package/skills/i3-ui-prototype/assets/templates/catalog.js +149 -0
  110. package/skills/i3-ui-prototype/assets/templates/catalog.json +2 -1
  111. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/capability-definition.json +294 -0
  112. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/component-inventory.json +707 -0
  113. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/generation.json +49 -0
  114. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/index.html +507 -2
  115. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline-build.json +265 -0
  116. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline.js +8888 -0
  117. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template-inventory.json +28791 -0
  118. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template.json +9 -0
  119. package/skills/i3-ui-prototype/assets/templates/gallery/index.html +5 -5
  120. package/skills/i3-ui-prototype/assets/templates/generation-options.js +42 -0
  121. package/skills/i3-ui-prototype/assets/templates/index.html +3 -0
  122. package/skills/i3-ui-prototype/assets/templates/presentation.html +17 -0
  123. package/skills/i3-ui-prototype/references/artifact-contract.md +26 -1
  124. package/skills/i3-ui-prototype/references/content-template.md +81 -0
  125. package/skills/i3-ui-prototype/references/enterprise-admin.md +6 -0
  126. package/skills/i3-ui-prototype/references/entry-contract.md +71 -0
  127. package/skills/i3-ui-prototype/references/execution-evidence.md +66 -0
  128. package/skills/i3-ui-prototype/references/expert-adapter.md +33 -5
  129. package/skills/i3-ui-prototype/references/framework-contract.md +76 -0
  130. package/skills/i3-ui-prototype/references/generation-contract.md +122 -0
  131. package/skills/i3-ui-prototype/references/interaction-acceptance.md +169 -0
  132. package/skills/i3-ui-prototype/references/legacy-native.md +58 -0
  133. package/skills/i3-ui-prototype/references/optional-skills.md +63 -0
  134. package/skills/i3-ui-prototype/references/project-layout.md +92 -0
  135. package/skills/i3-ui-prototype/references/template-adapters.md +7 -1
  136. package/skills/i3-ui-prototype/references/workflow-contract.md +62 -4
  137. package/skills/i3-ui-prototype/scripts/adapters/easel-html.cjs +1 -1
  138. package/skills/i3-ui-prototype/scripts/browser-workspace.cjs +72 -0
  139. package/skills/i3-ui-prototype/scripts/codebuddy-context.cjs +75 -0
  140. package/skills/i3-ui-prototype/scripts/codebuddy-hook.cjs +157 -0
  141. package/skills/i3-ui-prototype/scripts/codebuddy-ide-source.cjs +69 -0
  142. package/skills/i3-ui-prototype/scripts/codebuddy-registration.cjs +123 -0
  143. package/skills/i3-ui-prototype/scripts/content-template.cjs +98 -0
  144. package/skills/i3-ui-prototype/scripts/enterprise-template.cjs +13 -6
  145. package/skills/i3-ui-prototype/scripts/execution-assertions.cjs +43 -0
  146. package/skills/i3-ui-prototype/scripts/execution-evidence.cjs +146 -0
  147. package/skills/i3-ui-prototype/scripts/execution-gates.cjs +147 -0
  148. package/skills/i3-ui-prototype/scripts/execution-policy.cjs +41 -0
  149. package/skills/i3-ui-prototype/scripts/execution-runtime.cjs +60 -0
  150. package/skills/i3-ui-prototype/scripts/generation-assets.cjs +18 -0
  151. package/skills/i3-ui-prototype/scripts/generation-contract.cjs +214 -0
  152. package/skills/i3-ui-prototype/scripts/generation-evidence.cjs +50 -0
  153. package/skills/i3-ui-prototype/scripts/generation-replan.cjs +34 -0
  154. package/skills/i3-ui-prototype/scripts/hosts/codebuddy.cjs +16 -0
  155. package/skills/i3-ui-prototype/scripts/manage-baseline.cjs +1 -1
  156. package/skills/i3-ui-prototype/scripts/native-browser.cjs +785 -0
  157. package/skills/i3-ui-prototype/scripts/native-build.cjs +91 -0
  158. package/skills/i3-ui-prototype/scripts/native-delivery.cjs +103 -0
  159. package/skills/i3-ui-prototype/scripts/native-devtools.cjs +92 -0
  160. package/skills/i3-ui-prototype/scripts/native-files.cjs +35 -0
  161. package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +292 -0
  162. package/skills/i3-ui-prototype/scripts/native-pilot.cjs +50 -0
  163. package/skills/i3-ui-prototype/scripts/optional-skill-install.cjs +203 -0
  164. package/skills/i3-ui-prototype/scripts/project-registry.cjs +126 -0
  165. package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +81 -0
  166. package/skills/i3-ui-prototype/scripts/prototype-hooks.cjs +87 -0
  167. package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +31 -13
  168. package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +69 -0
  169. package/skills/i3-ui-prototype/scripts/sync-template.cjs +19 -0
  170. package/skills/i3-ui-prototype/scripts/template-contract.cjs +407 -0
  171. package/skills/i3-ui-prototype/scripts/template-inventory.cjs +78 -0
  172. package/skills/i3-ui-prototype/scripts/validate-result.cjs +117 -0
  173. package/skills/i3-using-agent-skills/SKILL.md +2 -5
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path');
3
+ const install=path.join(__dirname,'lib/installation.js');const skills=fs.existsSync(install)?require(install).loadInstallation().skillsDirectory:path.resolve(__dirname,'../skills');
4
+ const script=path.join(skills,'i3-ui-prototype/scripts/generation-evidence.cjs');
5
+ function fixture(t){const directory=fs.mkdtempSync(path.join(os.tmpdir(),'i3-generation-evidence-'));t.after(()=>fs.rmSync(directory,{recursive:true,force:true}));fs.mkdirSync(path.join(directory,'app/src'),{recursive:true});fs.writeFileSync(path.join(directory,'app/src/Actual.vue'),'<template><a-tree /></template>');return directory;}
6
+ const generation={bindings:[{id:'items/tree/hierarchy',capabilityId:'hierarchy'}],scenarios:[{id:'generation/items/tree/hierarchy/expand',bindingId:'items/tree/hierarchy'}]};
7
+ test('each capability maps to implementation and its exact scenarios before acceptance',t=>{const lib=require(script),dir=fixture(t),fixed=lib.initialize(generation);assert.equal(lib.validateImplementation(dir,generation,fixed).status,'pending');assert.throws(()=>lib.validateImplementation(dir,generation,fixed,{complete:true}),/pending/i);const item=fixed.bindings[0];item.status='implemented';item.components=['Tree'];item.files=[{path:'app/src/Actual.vue',locator:'a-tree'}];assert.equal(lib.validateImplementation(dir,generation,fixed,{complete:true}).status,'implemented');item.scenarioIds=[];assert.throws(()=>lib.validateImplementation(dir,generation,fixed),/scenario/i);});
8
+ test('deleting a binding, pointing to absent code, or using input documents cannot pass',t=>{const lib=require(script),dir=fixture(t),fixed=lib.initialize(generation);fixed.bindings=[];assert.throws(()=>lib.validateImplementation(dir,generation,fixed),/binding/i);fixed.bindings=lib.initialize(generation).bindings;Object.assign(fixed.bindings[0],{status:'implemented',components:['Tree'],files:[{path:'inputs/generation.json',locator:'Tree'}]});assert.throws(()=>lib.validateImplementation(dir,generation,fixed),/implementation|source/i);fixed.bindings[0].files[0].path='app/src/Missing.vue';assert.throws(()=>lib.validateImplementation(dir,generation,fixed),/ENOENT|Missing/);});
9
+ test('browser observations need every scenario, actual actions and immutable evidence bytes',t=>{const lib=require(script),dir=fixture(t);assert.throws(()=>lib.validateObservations(dir,generation,{}),/observation/i);fs.mkdirSync(path.join(dir,'qa'));fs.writeFileSync(path.join(dir,'qa/observation.txt'),'Synthetic test evidence, never a browser result.');const hash=require('node:crypto').createHash('sha256').update(fs.readFileSync(path.join(dir,'qa/observation.txt'))).digest('hex');const report={generationObservations:[{scenarioId:generation.scenarios[0].id,bindingId:generation.bindings[0].id,status:'passed',steps:[{action:'fixture action',target:'fixture tree',observed:'fixture result'}],toolCallIds:['fixture-call'],evidence:[{path:'qa/observation.txt',sha256:hash}]}]};assert.equal(lib.validateObservations(dir,generation,report).invocationVerified,false);report.generationObservations[0].steps=[];assert.throws(()=>lib.validateObservations(dir,generation,report),/step/i);report.generationObservations[0].steps=[{action:'fixture',target:'fixture',observed:'fixture'}];fs.appendFileSync(path.join(dir,'qa/observation.txt'),'changed');assert.throws(()=>lib.validateObservations(dir,generation,report),/evidence/i);});
10
+ test('framework mapping covers all semantic capabilities without device sample assumptions',()=>{const lib=require(script),catalog=require(path.join(skills,'i3-ui-prototype/assets/capabilities/generation.json')),map=lib.framework();assert.deepEqual(Object.keys(map.capabilities).sort(),Object.keys(catalog.capabilities).sort());assert.ok(!JSON.stringify(map).includes('useDevices'));assert.ok(!JSON.stringify(map).includes('21 deterministic'));});
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ const test = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), os = require('node:os'), path = require('node:path');
3
+ const install = path.join(__dirname, 'lib/installation.js');
4
+ const skills = fs.existsSync(install) ? require(install).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
5
+ const root = path.join(skills, 'i3-ui-prototype'), templates = require(path.join(root, 'scripts/template-contract.cjs')), native = require(path.join(root, 'scripts/native-pilot-lib.cjs'));
6
+ const read = file => JSON.parse(fs.readFileSync(file));
7
+ function fixture(t) {
8
+ const project = fs.mkdtempSync(path.join(os.tmpdir(), 'i3-generation-native-')); t.after(() => fs.rmSync(project, { recursive: true, force: true }));
9
+ const scope = { schemaVersion: 3, moduleIds: ['assets'], title: '资产', pages: [{ id: 'assets', title: '资产台账', referencePage: 'models-page', regions: [{ id: 'list', mode: 'content', pattern: 'tree-list' }] }], requirements: [{ id: 'A-01', text: '资产层级台账', pages: ['assets'], source: { type: 'prd', reference: 'fixture/A-01', statement: 'Synthetic source fixture only, not acceptance.' } }], generation: { presentation: { layout: 'mixed', style: 'soft', density: 'compact' } } };
10
+ const file = path.join(project, 'scope.json');fs.writeFileSync(file, JSON.stringify(scope));
11
+ return { project, scope, file, create: () => native.createPrototype({ project, id: 'assets', scope: file, expertMode: 'template' }) };
12
+ }
13
+ test('schema v3 creation freezes pattern contracts, customer choices and per-region acceptance', t => {
14
+ const { create, project } = fixture(t), result = create(), inputs = path.join(result.directory, 'inputs');
15
+ assert.equal(native.inspect(result.directory).status, 'passed');
16
+ const fixed = read(path.join(inputs, 'generation.json')), config = read(path.join(result.directory, 'app/public/prototype-config.json'));
17
+ assert.deepEqual(config.presentation, { layout: 'mixed', style: 'soft', density: 'compact' });
18
+ assert.equal(fixed.pages[0].regions[0].pattern, 'tree-list');
19
+ assert.ok(read(path.join(inputs, 'acceptance.json')).requiredScenarios.some(id => id.includes('/hierarchy/')));
20
+ assert.deepEqual(read(path.join(project, 'prototypes/index.json')).prototypes[0].appliesTo, ['assets']);
21
+ assert.equal(read(path.join(result.directory, 'prototype.json')).business.status, 'needs-adaptation');
22
+ assert.ok(fs.existsSync(path.join(result.directory, 'app/src/PrototypeShell.vue')));
23
+ });
24
+ test('generation constraints cannot be removed together with their acceptance IDs', t => {
25
+ const { create } = fixture(t), dir = create().directory, file = path.join(dir, 'inputs/generation.json');
26
+ const fixed = read(file);fixed.bindings = fixed.bindings.filter(item => item.capabilityId !== 'hierarchy');fs.writeFileSync(file, JSON.stringify(fixed));
27
+ const candidate = native.manifest(dir);
28
+ assert.throws(() => native.revise(dir, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true }), /generation|contract/i);
29
+ });
30
+ test('unsupported and planned native templates are rejected rather than falling back to enterprise', t => {
31
+ fixture(t);
32
+ assert.throws(() => templates.loadBundledTemplate('ai-workbench'), /available|Unsupported|planned/);
33
+ assert.throws(() => templates.loadBundledTemplate('../outside'), /identifier|template/i);
34
+ });
35
+ test('legacy sourced scopes keep the original input contract and no new presentation is forced', t => {
36
+ const { scope, file, create } = fixture(t); scope.schemaVersion = 2; delete scope.generation; delete scope.pages[0].regions[0].pattern;
37
+ fs.writeFileSync(file, JSON.stringify(scope)); const dir = create().directory;
38
+ assert.equal(fs.existsSync(path.join(dir, 'inputs/generation.json')), false);
39
+ assert.equal(read(path.join(dir, 'app/public/prototype-config.json')).presentation, undefined);
40
+ assert.equal(native.inspect(dir).status, 'passed');
41
+ });
42
+ test('another registered content template uses its own structure and defaults without a core ID branch', t => {
43
+ const { project, scope, file } = fixture(t), pack = path.join(project, 'template-pack'), directory = path.join(pack, 'alternate');fs.mkdirSync(directory, { recursive: true });
44
+ fs.writeFileSync(path.join(pack, 'catalog.json'), JSON.stringify({ defaultTemplate: 'alternate', templates: [{ id: 'alternate', availability: 'available', contentRoot: 'alternate' }] }));
45
+ fs.writeFileSync(path.join(directory, 'template.json'), JSON.stringify({ id: 'alternate', version: '1.0.0', kind: 'content-reference', entry: 'index.html', definition: 'capability-definition.json' }));
46
+ fs.copyFileSync(path.join(root, 'assets/capabilities/management.json'), path.join(directory, 'capability-definition.json'));
47
+ fs.writeFileSync(path.join(directory, 'index.html'), '<script id="prototype-offline-routes">[{"path":"/items","component":"./Items.js"}]</script>\n<template data-source="reference/Items.html">\n<main><h1>另一套内容</h1><template data-component="Button" data-on-click="view">查看</template></main>\n</template>\n');
48
+ fs.writeFileSync(path.join(directory, 'component-inventory.json'), JSON.stringify({ schemaVersion: 1, components: { Button: { kind: 'framework', capabilities: ['interaction'] } } }));
49
+ const meta = read(path.join(root, 'assets/templates/enterprise-admin/generation.json'));meta.templateId = 'alternate';meta.shell = null;meta.defaults = { layout: 'topbar', style: 'minimal', density: 'comfortable' };meta.patterns = ['content'];
50
+ fs.writeFileSync(path.join(directory, 'generation.json'), JSON.stringify(meta));templates.syncTemplate(directory);
51
+ assert.equal(templates.loadBundledTemplate('alternate', { templatesRoot: pack }).id, 'alternate');
52
+ scope.pages[0].referencePage = 'items';scope.pages[0].regions[0].pattern = 'content';delete scope.generation.presentation;fs.writeFileSync(file, JSON.stringify(scope));
53
+ const result = native.createPrototype({ project, id: 'alternate-result', scope: file, templateRoot: directory, expertMode: 'template' });
54
+ assert.equal(read(path.join(result.directory, 'prototype.json')).template.id, 'alternate');
55
+ assert.equal(read(path.join(result.directory, 'inputs/generation.json')).presentation.layout, 'topbar');
56
+ assert.equal(native.inspect(result.directory).status, 'passed');
57
+ });
58
+ test('marking a scaffold adapted cannot bypass the new implementation acceptance gate',t=>{
59
+ const {create}=fixture(t),dir=create().directory,file=path.join(dir,'prototype.json'),meta=read(file);meta.business.status='adapted';fs.writeFileSync(file,JSON.stringify(meta));
60
+ const checked=native.inspect(dir,{acceptance:true});assert.equal(checked.status,'failed');assert.match(checked.errors.join(' '),/Implementation pending/);
61
+ fs.unlinkSync(path.join(dir,'design-system/implementation.json'));assert.equal(native.inspect(dir).status,'failed');
62
+ });
63
+ test('replan refreshes the complete fixed contract atomically and refuses invalid scope or stale identity',t=>{
64
+ const {create,scope,file}=fixture(t),dir=create().directory,before=native.manifest(dir);assert.equal(typeof native.replan,'function');
65
+ scope.generation.presentation.layout='topbar';fs.writeFileSync(file,JSON.stringify(scope));
66
+ const next=native.replan(dir,{scope:file,revision:'r002',expectedRevision:before.revision,expectedDigest:before.digest});assert.equal(next.revision,'r002');
67
+ assert.equal(read(path.join(dir,'app/public/prototype-config.json')).presentation.layout,'topbar');assert.ok(fs.readFileSync(path.join(dir,'design-system/MASTER.md'),'utf8').includes('布局 topbar'));assert.equal(native.inspect(dir).status,'passed');
68
+ const stable=native.manifest(dir);scope.generation.presentation.layout='bad-layout';fs.writeFileSync(file,JSON.stringify(scope));
69
+ assert.throws(()=>native.replan(dir,{scope:file,revision:'r003',expectedRevision:stable.revision,expectedDigest:stable.digest}),/choice/);assert.equal(native.manifest(dir).digest,stable.digest);
70
+ assert.throws(()=>native.replan(dir,{scope:file,revision:'r003',expectedRevision:before.revision,expectedDigest:before.digest}),/Stale|changed/);
71
+ });
72
+ test('replan restores fixed bytes if revision admission fails after input writes',t=>{
73
+ const {create,scope,file}=fixture(t),dir=create().directory,before=native.manifest(dir);scope.generation.presentation.layout='topbar';fs.writeFileSync(file,JSON.stringify(scope));fs.mkdirSync(path.join(dir,'baselines/r002'),{recursive:true});
74
+ assert.throws(()=>native.replan(dir,{scope:file,revision:'r002',expectedRevision:before.revision,expectedDigest:before.digest}),/frozen/);assert.deepEqual(native.manifest(dir),before);assert.equal(native.inspect(dir).status,'passed');
75
+ });
76
+ test('v3 state keeps recovery compact while exposing pending decisions and exact commands',t=>{
77
+ const {create}=fixture(t),dir=create().directory,state=native.state(dir,{});assert.equal(state.nextAction,'resolve-generation-decisions');assert.ok(Buffer.byteLength(JSON.stringify(state))<25000);assert.ok(state.commands.replan);assert.ok(state.generation.referenceReview.pendingCount>0);assert.equal(state.scopes.sourceFile,'inputs/scope.json');
78
+ });
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ const test = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path');
3
+ const install = path.join(__dirname, 'lib/installation.js');
4
+ const skills = fs.existsSync(install) ? require(install).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
5
+ const root = path.join(skills, 'i3-ui-prototype'), templates = require(path.join(root, 'scripts/template-contract.cjs'));
6
+ const generation = require(path.join(root, 'scripts/generation-contract.cjs'));
7
+ function scope(pattern = 'management-list') {
8
+ return { schemaVersion: 3, moduleIds: ['assets'], pages: [{ id: 'assets', title: '资产', referencePage: 'models-page', regions: [{ id: 'main', mode: 'content', pattern }] }], requirements: [{ id: 'A-01', text: '按实际资产需求适配参考页面', pages: ['assets'], source: { type: 'prd', reference: 'fixture/A-01', statement: 'Synthetic contract fixture; not user or runtime acceptance.' } }], generation: { presentation: { layout: 'mixed', style: 'minimal', density: 'compact' }, decisions: [] } };
9
+ }
10
+ test('selected page patterns derive complete abilities and frozen customer presentation choices', () => {
11
+ const fixed = generation.compile(templates.loadBundledTemplate(), scope());
12
+ assert.deepEqual(fixed.presentation, { layout: 'mixed', style: 'minimal', density: 'compact' });
13
+ for (const capability of ['filter', 'selection', 'pagination', 'bulk', 'columns', 'feedback']) assert.ok(fixed.bindings.some(binding => binding.capabilityId === capability));
14
+ assert.ok(fixed.scenarios.some(scenario => scenario.id.includes('/columns/')));
15
+ assert.equal(fixed.bindings.find(binding => binding.capabilityId === 'pagination').parameters.defaultPageSize, 10);
16
+ assert.ok(fixed.referenceCoverage[0].events.length && fixed.referenceCoverage[0].nodes.length);
17
+ });
18
+ test('tree, tree table, wizard, canvas, permissions and dialogs have distinct required behavior', () => {
19
+ const snapshot = templates.loadBundledTemplate();
20
+ for (const [pattern, required] of Object.entries({ 'tree-list': ['hierarchy', 'pagination'], 'tree-table': ['hierarchy', 'columns'], wizard: ['wizard', 'dirty'], workflow: ['canvas', 'dirty'], permissions: ['authorization', 'hierarchy'], 'edit-form': ['edit', 'dialog', 'dirty'], detail: ['detail'] })) {
21
+ const fixed = generation.compile(snapshot, scope(pattern));
22
+ for (const capability of required) assert.ok(fixed.bindings.some(binding => binding.capabilityId === capability), `${pattern}/${capability}`);
23
+ }
24
+ });
25
+ test('unknown choices and deleting a required pattern capability fail instead of weakening the contract', () => {
26
+ const snapshot = templates.loadBundledTemplate(), input = scope(), fixed = generation.compile(snapshot, input);
27
+ for (const [key,value] of Object.entries({ layout: 'invented-layout', style: 'invented-style', density: 'invisible' })) {
28
+ const changed = structuredClone(input); changed.generation.presentation[key] = value;
29
+ assert.throws(() => generation.compile(snapshot, changed), /Unsupported|choice/);
30
+ }
31
+ fixed.bindings = fixed.bindings.filter(binding => binding.capabilityId !== 'columns');
32
+ assert.throws(() => generation.verify(snapshot, input, fixed), /contract|generation/i);
33
+ input.generation.decisions = [{ pageId: 'assets', capabilityId: 'columns', decision: 'not-applicable', requirementId: 'A-01', reason: 'Do not test columns' }];
34
+ assert.throws(() => generation.compile(snapshot, input), /required/i);
35
+ });
36
+ test('reference applicability is explicit and omissions remain pending until sourced decisions exist', () => {
37
+ const snapshot = templates.loadBundledTemplate(), input = scope(), fixed = generation.compile(snapshot, input);
38
+ assert.ok(fixed.pendingDecisions.length);
39
+ input.generation.decisions = fixed.pendingDecisions.map(item => ({ ...item, decision: 'not-applicable', requirementId: 'A-01', reason: 'This fixture intentionally narrows its stated business scope.' }));
40
+ assert.equal(generation.compile(snapshot, input).pendingDecisions.length, 0);
41
+ input.generation.decisions[0].reason = '';
42
+ assert.throws(() => generation.compile(snapshot, input), /reason|source/i);
43
+ });
44
+ test('frozen generation rules remain independent from future global catalog changes', () => {
45
+ const snapshot = templates.loadBundledTemplate(), input = scope(), fixed = generation.compile(snapshot, input);
46
+ assert.equal(generation.verify(snapshot, input, fixed).status, 'passed');
47
+ const changed = structuredClone(input); changed.pages[0].regions[0].pattern = 'tree-list';
48
+ assert.throws(() => generation.verify(snapshot, changed, fixed), /contract|generation/i);
49
+ });
50
+ test('business fields and actions remain sourced and receive individual acceptance identities', () => {
51
+ const snapshot = templates.loadBundledTemplate(), input = scope(), region = input.pages[0].regions[0];
52
+ region.fields = [{ id: 'name', label: '资产名称', kind: 'text', required: true, requirementId: 'A-01' }];
53
+ region.actions = [{ id: 'disable', label: '停用所选资产', kind: 'state-change', target: 'selected', outcome: '只有所选资产变为停用', requirementId: 'A-01' }];
54
+ const fixed = generation.compile(snapshot, input);
55
+ assert.equal(fixed.pages[0].regions[0].fields[0].label, '资产名称');
56
+ assert.ok(fixed.scenarios.some(item => item.id.endsWith('/action/disable')));
57
+ region.actions[0].requirementId = 'unknown';
58
+ assert.throws(() => generation.compile(snapshot, input), /source|requirement/);
59
+ });
60
+ test('unknown parameters and attempts to disable selected behavior fail validation', () => {
61
+ const snapshot = templates.loadBundledTemplate(), input = scope();
62
+ input.pages[0].regions[0].parameters = { columns: { enabled: false } };
63
+ assert.throws(() => generation.compile(snapshot, input), /parameter|Unsupported/i);
64
+ });
65
+ test('navigation groups preserve every declared page once and reject missing or duplicate routes', () => {
66
+ const snapshot = templates.loadBundledTemplate(), input = scope();
67
+ input.generation.navigation = [{ id: 'assets-group', title: '资产管理', children: [{ pageId: 'assets' }] }];
68
+ assert.equal(generation.compile(snapshot, input).navigation[0].children[0].key, 'assets');
69
+ input.generation.navigation[0].children.push({ pageId: 'assets' });
70
+ assert.throws(() => generation.compile(snapshot, input), /Duplicate|navigation/i);
71
+ });
72
+ test('every referenced functional node requires an explicit mapped or excluded disposition',()=>{
73
+ const templates=require(path.join(root,'scripts/template-contract.cjs')),lib=require(path.join(root,'scripts/generation-contract.cjs')),snapshot=templates.loadBundledTemplate();
74
+ const scope={schemaVersion:3,pages:[{id:'assets',title:'资产',referencePage:'models-page',regions:[{id:'list',pattern:'management-list'}]}],requirements:[{id:'A-01',pages:['assets'],source:{type:'prd',reference:'fixture',statement:'Fixture only.'}}],generation:{}};
75
+ const fixed=lib.compile(snapshot,scope);assert.ok(fixed.pendingReferenceElements.length);const first=fixed.pendingReferenceElements[0];
76
+ scope.generation.referenceDecisions=[{pageId:first.pageId,nodeIds:[first.nodeId],decision:'not-applicable',requirementId:'A-01',reason:'Synthetic fixture excludes this example node.'}];
77
+ assert.equal(lib.compile(snapshot,scope).pendingReferenceElements.length,fixed.pendingReferenceElements.length-1);
78
+ scope.generation.referenceDecisions.push(scope.generation.referenceDecisions[0]);assert.throws(()=>lib.compile(snapshot,scope),/duplicate reference element/);
79
+ });
80
+ test('fixed pagination and modal form scenarios match their actual parameters',()=>{
81
+ const templates=require(path.join(root,'scripts/template-contract.cjs')),lib=require(path.join(root,'scripts/generation-contract.cjs')),snapshot=templates.loadBundledTemplate();
82
+ const scope={schemaVersion:3,pages:[{id:'assets',title:'资产',referencePage:'models-page',regions:[{id:'list',pattern:'management-list',parameters:{pagination:{mode:'fixed',fixedPageSize:15,minimumTestPages:3}}},{id:'edit',pattern:'edit-form',parameters:{edit:{presentation:'modal'}}}]}],requirements:[{id:'A-01',pages:['assets'],source:{type:'prd',reference:'fixture',statement:'Fixture only.'}}]};
83
+ const fixed=lib.compile(snapshot,scope);assert.match(fixed.scenarios.find(s=>s.capabilityId==='pagination'&&s.id.endsWith('page-size')).expected,/固定容量/);assert.equal(fixed.bindings.find(s=>s.capabilityId==='dialog').parameters.presentation,'modal');
84
+ scope.pages[0].regions[1].parameters.dialog={presentation:'drawer'};assert.throws(()=>lib.compile(snapshot,scope),/presentation|呈现/);
85
+ });
86
+ test('optional shell and second-region capabilities can be adopted without borrowing another region binding',()=>{
87
+ const snapshot=templates.loadBundledTemplate(),input=scope();input.pages[0].regions.push({id:'second',pattern:'content'});
88
+ input.generation.decisions=[{pageId:'app-shell',regionId:'shell',capabilityId:'menu',decision:'adopted',requirementId:'A-01',reason:'Fixture shell menu.'},{pageId:'assets',regionId:'second',capabilityId:'columns',decision:'adopted',requirementId:'A-01',reason:'Fixture second table.'}];
89
+ const fixed=generation.compile(snapshot,input);assert.ok(fixed.bindings.some(b=>b.id==='app-shell/shell/menu'));assert.ok(fixed.bindings.some(b=>b.id==='assets/second/columns'));
90
+ });
91
+ test('adopted reference controls get individual scenarios even when they share a core capability',()=>{
92
+ const snapshot=templates.loadBundledTemplate(),input=scope(),draft=generation.compile(snapshot,input),node=draft.pendingReferenceElements.find(item=>item.events.length&&(!item.capabilities.length||item.capabilities.includes('interaction')));
93
+ input.generation.referenceDecisions=[{pageId:node.pageId,nodeIds:[node.nodeId],decision:'mapped',bindingIds:['app-shell/shell/interaction'],requirementId:'A-01',reason:'Fixture adapts this control to its sourced outcome.'}];
94
+ const fixed=generation.compile(snapshot,input);assert.ok(fixed.scenarios.some(item=>item.referenceNodeId===node.nodeId&&item.bindingId==='app-shell/shell/interaction'));
95
+ });
96
+ test('read-only planning exposes compact summaries and individual reference fragments',()=>{
97
+ const fixed=generation.compile(templates.loadBundledTemplate(),scope()),summary=generation.view(fixed);assert.equal(summary.readOnly,true);assert.ok(summary.referenceReview.pendingCount>0);assert.ok(!summary.definition);assert.ok(Buffer.byteLength(JSON.stringify(summary))<25000);
98
+ const group=summary.referenceReview.fragments[0],detail=generation.view(fixed,{page:group.pageId,fragment:group.fragment});assert.ok(detail.referenceReview.elements.length);assert.ok(detail.referenceReview.elements.every(item=>item.fragment===group.fragment));assert.throws(()=>generation.view(fixed,{page:'missing'}),/page/);
99
+ });
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ const test = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), os = require('node:os');
3
+ const install = path.join(__dirname, 'lib/installation.js');
4
+ const skills = fs.existsSync(install) ? require(install).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
5
+ const root = path.join(skills, 'i3-ui-prototype'), inventory = require(path.join(root, 'scripts/template-inventory.cjs'));
6
+ const builtin = path.join(root, 'assets/templates/enterprise-admin');
7
+ test('the complete reference inventory accounts for every route, fragment, component and event', () => {
8
+ const value = inventory.scan(builtin);
9
+ assert.equal(value.routes.length, 44); assert.equal(value.pages.length, 35); assert.equal(value.fragments.length, 93);
10
+ assert.equal(Object.keys(value.components).length, 116); assert.equal(value.eventCount, 595);
11
+ assert.equal(value.unknownComponents.length, 0);
12
+ assert.ok(value.fragments.every(fragment => fragment.nodes.length && fragment.digest));
13
+ assert.ok(value.fragments.every(fragment => fragment.nodes.every(node => node.id && node.tag && node.attributes)));
14
+ assert.equal(value.components.Tree.classification.capabilities.includes('hierarchy'), true);
15
+ assert.ok(value.files['reference/src/shared/columns.js']);
16
+ assert.ok(value.components.ColumnSettings.classification.source.endsWith('ColumnSettings.html'));
17
+ });
18
+ function fixture(t) {
19
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'i3-inventory-'));
20
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
21
+ fs.writeFileSync(path.join(directory, 'index.html'), '<script type="application/json" id="prototype-offline-routes">[{"path":"/items","component":"./Items.js"}]</script>\n<template data-source="reference/Items.html">\n<main><template data-component="Button" data-on-click="save">保存</template></main>\n</template>\n');
22
+ fs.writeFileSync(path.join(directory, 'component-inventory.json'), JSON.stringify({ schemaVersion: 1, components: { Button: { kind: 'framework', capabilities: ['interaction'] } } }));
23
+ return directory;
24
+ }
25
+ test('a removed event, route or non-HTML source file cannot keep the old complete inventory', t => {
26
+ const directory = fixture(t), file = path.join(directory, 'index.html'), html = fs.readFileSync(file, 'utf8');
27
+ fs.writeFileSync(path.join(directory, 'domain.js'), 'original behavior');
28
+ const baseline = inventory.scan(directory);
29
+ for (const change of [html.replace(' data-on-click="save"', ''), html.replace('/items', '/different')]) {
30
+ fs.writeFileSync(file, change); assert.throws(() => inventory.verify(directory, baseline), /inventory|changed|清单/i);
31
+ }
32
+ fs.writeFileSync(file, html); fs.unlinkSync(path.join(directory, 'domain.js'));
33
+ assert.throws(() => inventory.verify(directory, baseline), /inventory|changed|清单/i);
34
+ });
35
+ test('unclassified components and unsafe template files fail closed', t => {
36
+ const directory = fixture(t), file = path.join(directory, 'index.html');
37
+ fs.appendFileSync(file, '\n<template data-source="reference/Other.html">\n<template data-component="NewWidget"></template>\n</template>\n');
38
+ assert.throws(() => inventory.scan(directory), /Unclassified.*NewWidget/);
39
+ fs.unlinkSync(file); fs.writeFileSync(file, '<script id="prototype-offline-routes">[]</script>');
40
+ fs.symlinkSync(os.tmpdir(), path.join(directory, 'outside'));
41
+ assert.throws(() => inventory.scan(directory), /symlink/i);
42
+ });
43
+ test('quoted comparison expressions remain complete nodes in replacement templates',t=>{const directory=fixture(t),file=path.join(directory,'index.html');fs.writeFileSync(file,fs.readFileSync(file,'utf8').replace('<main>','<main data-bind-visible="a > b && c < d">'));const fixed=inventory.scan(directory);assert.equal(fixed.fragments[0].nodes[0].attributes['data-bind-visible'],'a > b && c < d');});
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const installation = path.join(__dirname, 'lib/installation.js');
8
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
9
+ const locations = require(path.join(skills, 'i3-ui-prototype/scripts/prototype-locations.cjs'));
10
+ function workspace(t) {
11
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'i3-tool-locations-')));
12
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
13
+ const project = path.join(root, 'business'), home = path.join(root, 'home');
14
+ fs.mkdirSync(project); fs.mkdirSync(home);
15
+ return { project, home, env: {} };
16
+ }
17
+ test('all registered project and global targets keep tools under their host base without writes', t => {
18
+ const options = workspace(t);
19
+ for (const agent of locations.registry.agents) {
20
+ const local = locations.resolveLocations({ ...options, host: agent.id });
21
+ assert.equal(local.skillsDirectory, path.resolve(options.project, agent.projectSkillsDir));
22
+ const toolBase = path.dirname(local.skillsDirectory) === options.project ? path.join(options.project, `.${agent.id}`) : path.dirname(local.skillsDirectory);
23
+ assert.equal(local.toolsDirectory, path.join(toolBase, 'tools/i3-prototype'));
24
+ assert.notEqual(local.toolsDirectory, path.join(options.project, 'tools/i3-prototype'));
25
+ assert.equal(local.cacheDirectory, path.join(toolBase, 'cache/i3-prototype'));
26
+ if (agent.globalSkillsDir) {
27
+ const global = locations.resolveLocations({ ...options, host: agent.id, scope: 'global' });
28
+ assert.equal(global.toolsDirectory, path.join(path.dirname(global.skillsDirectory), 'tools/i3-prototype'));
29
+ assert.ok(global.base.startsWith(options.home + path.sep));
30
+ assert.equal(global.cacheDirectory, path.join(global.base, 'cache/i3-prototype', global.projectKey));
31
+ } else assert.throws(() => locations.resolveLocations({ ...options, host: agent.id, scope: 'global' }), /project installation only/);
32
+ }
33
+ assert.deepEqual(fs.readdirSync(options.project), []);
34
+ assert.deepEqual(fs.readdirSync(options.home), []);
35
+ });
36
+ test('global caches distinguish same-name projects while path resolution remains read-only', t => {
37
+ const options = workspace(t), other = path.join(options.home, 'business'); fs.mkdirSync(other);
38
+ const first = locations.resolveLocations({ ...options, host: 'codebuddy', scope: 'global' });
39
+ const second = locations.resolveLocations({ ...options, project: other, host: 'codebuddy', scope: 'global' });
40
+ assert.notEqual(first.projectKey, second.projectKey);
41
+ assert.notEqual(first.cacheDirectory, second.cacheDirectory);
42
+ assert.deepEqual(fs.readdirSync(options.project), []);
43
+ assert.equal(fs.existsSync(path.join(options.home, '.codebuddy')), false);
44
+ });
45
+ test('CLI path output distinguishes installation scope from the preparation scope file without writes', async t => {
46
+ const options = workspace(t), cli = require(path.join(skills, 'i3-ui-prototype/scripts/native-pilot.cjs'));
47
+ for (const scope of ['project', 'global']) {
48
+ const result = await cli.main(['paths', '--project', options.project, '--home', options.home, '--host', 'codebuddy', '--scope', scope, '--id', 'assets']);
49
+ assert.equal(result.scope, scope);
50
+ assert.equal(result.scopeFile, path.join(options.project, 'prototypes/.preparation/assets/scope.json'));
51
+ }
52
+ assert.deepEqual(fs.readdirSync(options.project), []);
53
+ assert.deepEqual(fs.readdirSync(options.home), []);
54
+ });
55
+ test('CodeBuddy and WorkBuddy share project tools but use separate global roots; Codex follows agents', t => {
56
+ const options = workspace(t), get = (host, scope = 'project') => locations.resolveLocations({ ...options, host, scope });
57
+ assert.equal(get('codebuddy').toolsDirectory, get('workbuddy').toolsDirectory);
58
+ assert.notEqual(get('codebuddy', 'global').toolsDirectory, get('workbuddy', 'global').toolsDirectory);
59
+ assert.equal(get('codex').base, path.join(options.project, '.agents'));
60
+ assert.equal(get('codex', 'global').base, path.join(options.home, '.agents'));
61
+ });
62
+ test('configuration roots and aliases follow installer mapping and explicit global checks exclude business paths', t => {
63
+ const options = workspace(t);
64
+ const context = locations.resolveLocations({ ...options, host: 'claude', scope: 'global', env: { CLAUDE_CONFIG_DIR: path.join(options.home, 'custom claude') } });
65
+ assert.equal(context.base, path.join(options.home, 'custom claude'));
66
+ assert.equal(context.host, 'claude-code');
67
+ const opencode = locations.resolveLocations({ ...options, host: 'opencode', env: { XDG_CONFIG_HOME: path.join(options.home, 'config') }, scope: 'global' });
68
+ assert.equal(opencode.base, path.join(options.home, 'config/opencode'));
69
+ assert.ok(locations.toolCandidates(context).every(item => !item.root.startsWith(options.project + path.sep)));
70
+ assert.throws(() => locations.resolveLocations({ ...options, host: 'unknown' }), /Unknown host/);
71
+ assert.throws(() => locations.resolveLocations({ ...options, scope: 'all' }), /scope/);
72
+ });
73
+ test('new project and global tools precede both legacy layouts', t => {
74
+ const options = workspace(t), context = locations.resolveLocations({ ...options, host: 'codex' });
75
+ const paths = locations.toolCandidates(context);
76
+ assert.deepEqual(paths.map(item => item.source), ['project-tool', 'global-tool', 'legacy-project-tool', 'legacy-project-tool']);
77
+ assert.ok(paths[0].root.includes('/.agents/tools/i3-prototype/antdv-cli/'));
78
+ assert.ok(paths.at(-1).root.includes('/.prototype-tools/node_modules/'));
79
+ });
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const installation = path.join(__dirname, 'lib/installation.js');
8
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
9
+ const scripts = path.join(skills, 'i3-ui-prototype/scripts');
10
+ const native = require(path.join(scripts, 'native-pilot-lib.cjs'));
11
+ const registry = require(path.join(scripts, 'project-registry.cjs'));
12
+ const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
13
+ function fixture(t) {
14
+ const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'i3-project-')));
15
+ t.after(() => fs.rmSync(project, { recursive: true, force: true }));
16
+ const scope = { schemaVersion: 2, moduleIds: ['asset-management', 'work-orders', 'asset-management'], title: '资产原型',
17
+ pages: [{ id: 'assets', title: '资产台账', referencePage: 'models-page', regions: [{ id: 'list', mode: 'management-list' }] }],
18
+ requirements: [{ id: 'ASSET-01', text: '查询、多选、分页和批量状态变更', pages: ['assets'], source: { type: 'prd', reference: 'PRD-assets-v1.2#ASSET-01', statement: 'Isolated fixture with explicitly named project modules.' } }], exemptions: [] };
19
+ const file = path.join(project, 'scope.json');
20
+ const create = (id = 'assets') => { fs.writeFileSync(file, JSON.stringify(scope)); return native.createPrototype({ project, id, scope: file, expertMode: 'template' }); };
21
+ return { project, scope, create };
22
+ }
23
+ test('new prototypes use one shared nested index and derive module associations from fixed scope', t => {
24
+ const { project, scope, create } = fixture(t), created = create();
25
+ const index = read(path.join(project, 'prototypes/index.json'));
26
+ assert.deepEqual(index.prototypes[0].appliesTo, ['asset-management', 'work-orders']);
27
+ assert.equal(index.prototypes[0].directory, 'prototypes/assets');
28
+ assert.deepEqual(read(path.join(created.directory, 'inputs/scope.json')), scope);
29
+ for (const name of ['.i3-ui-prototypes.json', '.i3-ui-prototypes.lock', '.prototype-preparation', '.playwright-cli', 'ui-templates']) assert.equal(fs.existsSync(path.join(project, name)), false, name);
30
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/.index.lock')), false);
31
+ });
32
+ test('scope module validation rejects invented paths and malformed declarations before any output', t => {
33
+ const { project, scope, create } = fixture(t);
34
+ for (const value of [undefined, null, 'assets', [null], ['../assets'], ['src/assets'], ['*'], [''], ['asset management']]) {
35
+ scope.moduleIds = value;
36
+ assert.throws(() => create(), /moduleIds|module identifier/i);
37
+ assert.equal(fs.existsSync(path.join(project, 'prototypes')), false);
38
+ }
39
+ scope.moduleIds = [];
40
+ create();
41
+ assert.deepEqual(registry.query({ project, module: 'asset-management' }).matches, []);
42
+ assert.deepEqual(registry.query({ project }).unbound.map(entry => entry.id), ['assets']);
43
+ });
44
+ test('index lookup is read-only, detects overlapping prototypes and never treats unbound as global', t => {
45
+ const { project, scope, create } = fixture(t);
46
+ assert.equal(registry.query({ project }).prototypes.length, 0);
47
+ assert.deepEqual(fs.readdirSync(project), []);
48
+ create('first'); create('second');
49
+ const result = registry.query({ project, module: 'asset-management' });
50
+ assert.equal(result.selection, 'ambiguous');
51
+ assert.deepEqual(result.matches.map(entry => entry.id), ['first', 'second']);
52
+ scope.moduleIds = []; create('exploration');
53
+ assert.equal(registry.query({ project, module: 'work-orders' }).matches.length, 2);
54
+ assert.equal(registry.query({ project, module: 'other' }).matches.length, 0);
55
+ });
56
+ test('a legacy-only project keeps one active index until explicitly migrated and preserves its instances', t => {
57
+ const { project, create } = fixture(t), first = create('old');
58
+ const before = native.manifest(first.directory);
59
+ fs.renameSync(path.join(project, 'prototypes/index.json'), path.join(project, '.i3-ui-prototypes.json'));
60
+ create('new');
61
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/index.json')), false);
62
+ assert.equal(registry.query({ project }).legacy, true);
63
+ assert.equal(read(path.join(project, '.i3-ui-prototypes.json')).prototypes.length, 2);
64
+ assert.deepEqual(native.manifest(first.directory), before);
65
+ });
66
+ test('conflicting indices and an old active writer block creation without overwriting user content', t => {
67
+ const { project, create } = fixture(t); create('first');
68
+ const before = fs.readFileSync(path.join(project, 'prototypes/index.json'));
69
+ fs.writeFileSync(path.join(project, '.i3-ui-prototypes.json'), JSON.stringify({ schemaVersion: 1, prototypes: [] }));
70
+ assert.throws(() => create('second'), /conflict|indices/i);
71
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/second')), false);
72
+ assert.deepEqual(fs.readFileSync(path.join(project, 'prototypes/index.json')), before);
73
+ fs.unlinkSync(path.join(project, '.i3-ui-prototypes.json'));
74
+ fs.writeFileSync(path.join(project, '.i3-ui-prototypes.lock'), 'existing writer');
75
+ assert.throws(() => create('second'), /lock|writer/i);
76
+ assert.equal(fs.readFileSync(path.join(project, '.i3-ui-prototypes.lock'), 'utf8'), 'existing writer');
77
+ });
78
+ test('failed atomic index publication removes only the newly created prototype', t => {
79
+ const { project, create } = fixture(t); create('first');
80
+ const original = fs.readFileSync(path.join(project, 'prototypes/index.json'));
81
+ const rename = fs.renameSync;
82
+ fs.renameSync = (from, to) => { if (to === path.join(project, 'prototypes/index.json')) throw new Error('fixture index write failed'); return rename(from, to); };
83
+ try { assert.throws(() => create('second'), /fixture index write failed/); } finally { fs.renameSync = rename; }
84
+ assert.deepEqual(fs.readFileSync(path.join(project, 'prototypes/index.json')), original);
85
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/second')), false);
86
+ assert.deepEqual(fs.readdirSync(path.join(project, 'prototypes')).sort(), ['first', 'index.json']);
87
+ });
88
+ test('old scope contracts remain valid and legacy HTML creation also uses the shared index', t => {
89
+ const { project, scope, create } = fixture(t);
90
+ scope.schemaVersion = 1; delete scope.moduleIds;
91
+ const first = create();
92
+ assert.equal(native.inspect(first.directory).status, 'passed');
93
+ assert.deepEqual(registry.query({ project }).unbound.map(entry => entry.id), ['assets']);
94
+ const legacy = require(path.join(scripts, 'prototype-lib.cjs'));
95
+ const html = legacy.initPrototype({ project, id: 'html', template: 'classic-admin', expertMode: 'template' });
96
+ assert.equal(legacy.checkPrototype(html.directory).status, 'passed');
97
+ assert.equal(registry.query({ project }).prototypes.length, 2);
98
+ assert.equal(fs.existsSync(path.join(project, '.i3-ui-prototypes.json')), false);
99
+ });
100
+ test('legacy human module labels remain searchable without rewriting their old scope', t => {
101
+ const { project, scope, create } = fixture(t); scope.schemaVersion = 1; delete scope.moduleIds;
102
+ const directory = create().directory, before = native.manifest(directory), file = path.join(project, 'prototypes/index.json'), index = read(file);
103
+ index.prototypes[0].appliesTo = ['资产管理']; fs.writeFileSync(file, JSON.stringify(index));
104
+ assert.equal(registry.query({ project, module: '资产管理' }).matches[0].id, 'assets');
105
+ assert.deepEqual(native.manifest(directory), before);
106
+ });
107
+ test('explicitly accepting a revised scope updates the index and invalid associations cannot be selected', t => {
108
+ const { project, create } = fixture(t), directory = create().directory;
109
+ const scopeFile = path.join(directory, 'inputs/scope.json'), scope = read(scopeFile);
110
+ scope.moduleIds = ['maintenance']; fs.writeFileSync(scopeFile, JSON.stringify(scope));
111
+ assert.equal(registry.query({ project, module: 'asset-management' }).status, 'failed');
112
+ assert.deepEqual(registry.query({ project, module: 'asset-management' }).matches, []);
113
+ const candidate = native.manifest(directory);
114
+ native.revise(directory, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true });
115
+ assert.deepEqual(read(path.join(project, 'prototypes/index.json')).prototypes[0].appliesTo, ['maintenance']);
116
+ assert.equal(registry.query({ project, module: 'maintenance' }).selection, 'matched');
117
+ assert.equal(native.inspect(directory).status, 'passed');
118
+ });
119
+ test('synchronizing the index uses fixed accepted inputs and leaves the candidate unchanged', t => {
120
+ const { project, create } = fixture(t), directory = create().directory, before = native.manifest(directory);
121
+ const indexFile = path.join(project, 'prototypes/index.json'), index = read(indexFile);
122
+ index.prototypes[0].appliesTo = ['invented']; fs.writeFileSync(indexFile, JSON.stringify(index));
123
+ assert.equal(registry.query({ project, module: 'invented' }).status, 'failed');
124
+ const rename = fs.renameSync; let instanceLocked = false;
125
+ fs.renameSync = (from, to) => { if (to === indexFile) instanceLocked = fs.existsSync(path.join(directory, '.prototype-write.lock')); return rename(from, to); };
126
+ try { assert.equal(native.synchronizeIndex({ project, id: 'assets' }).status, 'synchronized'); } finally { fs.renameSync = rename; }
127
+ assert.equal(instanceLocked, true, 'Synchronization must lock the candidate while reading its fixed inputs');
128
+ assert.deepEqual(native.manifest(directory), before);
129
+ assert.equal(registry.query({ project, module: 'invented' }).matches.length, 0);
130
+ assert.equal(registry.query({ project, module: 'asset-management' }).matches.length, 1);
131
+ const scope = read(path.join(directory, 'inputs/scope.json')); scope.moduleIds = ['pending'];
132
+ fs.writeFileSync(path.join(directory, 'inputs/scope.json'), JSON.stringify(scope));
133
+ assert.throws(() => native.synchronizeIndex({ project, id: 'assets' }), /input|contract|合同/i);
134
+ });
135
+ test('an index failure while revising restores prototype metadata and preserves the previous registry', t => {
136
+ const { project, create } = fixture(t), directory = create().directory;
137
+ const metadataFile = path.join(directory, 'prototype.json'), before = fs.readFileSync(metadataFile);
138
+ const indexFile = path.join(project, 'prototypes/index.json'), indexBefore = fs.readFileSync(indexFile);
139
+ const scopeFile = path.join(directory, 'inputs/scope.json'), scope = read(scopeFile); scope.moduleIds = ['maintenance']; fs.writeFileSync(scopeFile, JSON.stringify(scope));
140
+ const candidate = native.manifest(directory), rename = fs.renameSync;
141
+ fs.renameSync = (from, to) => { if (to === indexFile) throw new Error('fixture revise index failure'); return rename(from, to); };
142
+ try { assert.throws(() => native.revise(directory, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true }), /fixture revise index failure/); }
143
+ finally { fs.renameSync = rename; }
144
+ assert.deepEqual(fs.readFileSync(metadataFile), before);
145
+ assert.deepEqual(fs.readFileSync(indexFile), indexBefore);
146
+ });
147
+ test('legacy index migration previews without writes, archives source bytes and preserves prototype identity', t => {
148
+ const { project, create } = fixture(t), directory = create().directory, candidate = native.manifest(directory);
149
+ const current = path.join(project, 'prototypes/index.json'), old = path.join(project, '.i3-ui-prototypes.json');
150
+ fs.renameSync(current, old); const bytes = fs.readFileSync(old), before = fs.readdirSync(path.join(project, 'prototypes'));
151
+ const preview = registry.migrate({ project, check: true });
152
+ assert.equal(preview.status, 'migration-preview');
153
+ assert.equal(fs.existsSync(current), false);
154
+ assert.deepEqual(fs.readdirSync(path.join(project, 'prototypes')), before);
155
+ const result = registry.migrate({ project });
156
+ assert.equal(result.status, 'migrated');
157
+ assert.deepEqual(fs.readFileSync(result.backup), bytes);
158
+ assert.deepEqual(fs.readFileSync(current), bytes);
159
+ assert.equal(fs.existsSync(old), false);
160
+ assert.deepEqual(native.manifest(directory), candidate);
161
+ assert.equal(registry.migrate({ project }).status, 'current');
162
+ });
163
+ test('failed migration rolls back its new index and retains original source and user files', t => {
164
+ const { project, create } = fixture(t); create();
165
+ const current = path.join(project, 'prototypes/index.json'), old = path.join(project, '.i3-ui-prototypes.json');
166
+ fs.renameSync(current, old); const bytes = fs.readFileSync(old);
167
+ fs.writeFileSync(path.join(project, 'user.md'), 'keep user content');
168
+ const unlink = fs.unlinkSync;
169
+ fs.unlinkSync = file => { if (file === old) throw new Error('fixture old source cannot be removed'); return unlink(file); };
170
+ try { assert.throws(() => registry.migrate({ project }), /fixture old source/); } finally { fs.unlinkSync = unlink; }
171
+ assert.deepEqual(fs.readFileSync(old), bytes);
172
+ assert.equal(fs.existsSync(current), false);
173
+ assert.equal(fs.readFileSync(path.join(project, 'user.md'), 'utf8'), 'keep user content');
174
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/.index.lock')), false);
175
+ assert.equal(fs.existsSync(path.join(project, '.i3-ui-prototypes.lock')), false);
176
+ });
177
+ test('migration refuses divergent duplicate indices rather than picking a winner', t => {
178
+ const { project, create } = fixture(t); create();
179
+ const current = path.join(project, 'prototypes/index.json'), bytes = fs.readFileSync(current);
180
+ fs.writeFileSync(path.join(project, '.i3-ui-prototypes.json'), '{"schemaVersion":1,"prototypes":[]}');
181
+ assert.throws(() => registry.migrate({ project }), /conflict|indices/i);
182
+ assert.deepEqual(fs.readFileSync(current), bytes);
183
+ });