diff --git a/docs/testing/main-capability-coverage.json b/docs/testing/main-capability-coverage.json index fc244b12..3815119b 100644 --- a/docs/testing/main-capability-coverage.json +++ b/docs/testing/main-capability-coverage.json @@ -2,7 +2,7 @@ "version": 1, "audit": { "base": "2b34c653119bdf480f2af0330ee3809b51441807", - "head": "86a597dbe65633fd3328534788890febeb1644fd", + "head": "4b3d7e25531865cbdd203830e71c964a57207de8", "ignoredDocumentationCommits": [ "dd86a325e3db5aa013ffa18a647e67e9fa279c10", "0b0e12b4d97ec7d77a8a93076459fdaad2e52070", @@ -476,7 +476,8 @@ "4b2ceebe6e7514c388fc64cfce1bfb9ffd37404a", "156080d8a49e51ec8f86d77538c428cde7831bd4", "4cf025e589a2f7426ae1f68d40c774429b6b9855", - "8aeef7b953132be14677977d21d41ffe1e051dd0" + "8aeef7b953132be14677977d21d41ffe1e051dd0", + "6cc7fca077b4929411d6dbaede79e8e560346ece" ] }, "capabilities": [ @@ -2352,7 +2353,8 @@ "3b26e9c4214ed9784eefe80a228003bcd1d185d1", "b7d6c83aecc814bf517321a9c65667792e5ca835", "630595db25f3f3723c85aa863de001982a1f6aaa", - "86a597dbe65633fd3328534788890febeb1644fd" + "86a597dbe65633fd3328534788890febeb1644fd", + "4b3d7e25531865cbdd203830e71c964a57207de8" ], "behaviors": [ "ARCH-POLICY-SCHEMA-001", diff --git a/scripts/architecture/trustedKernel.js b/scripts/architecture/trustedKernel.js index f4e534c7..f5f3bafe 100644 --- a/scripts/architecture/trustedKernel.js +++ b/scripts/architecture/trustedKernel.js @@ -21,6 +21,9 @@ const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); +// One canonical glob implementation (charter: one canonical truth) — the +// kernel and the legacy harness must classify with identical semantics. +const { compileGlob } = require('./loadArchitecturePolicy'); const ROOT = path.resolve(__dirname, '..', '..'); @@ -35,11 +38,6 @@ function readJson(filePath, errors) { } } -function compileGlob(pattern) { - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '<<>>').replace(/\*/g, '[^/]*').replace(/<<>>/g, '.*'); - return new RegExp(`^${escaped}$`); -} - // ── policy loading (from the given root directory) ─────────────────── function loadPolicy(rootDir, errors) { @@ -56,6 +54,7 @@ function loadPolicy(rootDir, errors) { roles: (mod.roles || []).map(role => ({ role: role.role, include: (role.include || []).map(compileGlob), + rawInclude: (role.include || []).slice(), })), })); @@ -89,11 +88,30 @@ function loadPolicy(rootDir, errors) { // ── file discovery ─────────────────────────────────────────────────── +// The head tree is materialized into a plain directory (no .git), so +// discovery walks the filesystem instead of shelling out to git. function discoverFiles(rootDir) { - const out = execFileSync('git', ['ls-files', '--cached', '--', 'src/'], { - cwd: rootDir, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, - }).trim(); - return out.split('\n').filter(line => line.endsWith('.ts') && !line.endsWith('.d.ts')); + const files = []; + const walk = dir => { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.isFile()) { continue; } + const rel = path.relative(rootDir, full).split(path.sep).join('/'); + if (rel.endsWith('.ts') && !rel.endsWith('.d.ts')) { files.push(rel); } + } + }; + walk(path.join(rootDir, 'src')); + return files.sort(); } // ── closed-world classification ────────────────────────────────────── @@ -114,16 +132,33 @@ function classifyFiles(files, modules, errors) { continue; } const mod = owners[0]; - const roleMatches = mod.roles.filter(r => r.include.some(pat => pat.test(file))); - if (roleMatches.length === 0) { - errors.push(`closed-world: ${file} (module ${mod.id}) has no matching role`); + // Remainder-role rule (same as the canonical policy loader): a role + // whose include is exactly ["**"] must be last and matches only + // module files no earlier role claimed. Overlap between + // non-remainder roles is an error, never silently first-match. + const remainderIndex = mod.roles.findIndex(candidate => { + const raw = candidate.rawInclude || []; + return raw.length === 1 && raw[0] === '**'; + }); + if (remainderIndex !== -1 && remainderIndex !== mod.roles.length - 1) { + errors.push(`policy: ${mod.id} remainder role '**' must be the last role`); continue; } + const specific = remainderIndex === -1 ? mod.roles : mod.roles.slice(0, remainderIndex); + const roleMatches = specific.filter(r => r.include.some(pat => pat.test(file))); if (roleMatches.length > 1) { errors.push(`closed-world: ${file} (module ${mod.id}) matches multiple roles: ${roleMatches.map(r => r.role).join(', ')}`); continue; } - classified.push({ file, module: mod.id, role: roleMatches[0].role }); + if (roleMatches.length === 1) { + classified.push({ file, module: mod.id, role: roleMatches[0].role }); + continue; + } + if (remainderIndex !== -1) { + classified.push({ file, module: mod.id, role: mod.roles[remainderIndex].role }); + continue; + } + errors.push(`closed-world: ${file} (module ${mod.id}) has no matching role`); } return classified; } diff --git a/tests/unit/architecture/trustedKernel.test.js b/tests/unit/architecture/trustedKernel.test.js index e1915b72..cf1562fd 100644 --- a/tests/unit/architecture/trustedKernel.test.js +++ b/tests/unit/architecture/trustedKernel.test.js @@ -110,6 +110,53 @@ test('TRUSTED-KERNEL-001 controlled mutation: a file in two roles fails', () => fs.rmSync(root, { recursive: true, force: true }); }); +// ── remainder-role rule (canonical parity) ──────────────────────────── + +test('TRUSTED-KERNEL-001 the "**" remainder role claims only files no earlier role claimed', () => { + const root = tmpDir(); + writeTree(root, { + 'src/alpha/index.ts': 'export const x = 1;', + 'src/alpha/internal/helper.ts': 'export const y = 2;', + 'docs/testing/architecture-modules.json': JSON.stringify({ version: 1, scope: { roots: ['src'] }, modules: [ + { id: 'MOD-ALPHA', source: { include: ['src/**'], exclude: [] }, publicEntrypoints: ['src/alpha/index.ts'], mayDependOn: [], productCapabilities: ['CAP-1'], roles: [ + { role: 'composition', include: ['src/alpha/index.ts'] }, + { role: 'application', include: ['**'] }, + ]}, + ]}), + 'docs/testing/main-capability-coverage.json': JSON.stringify({ version: 1, capabilities: [{ id: 'CAP-1' }] }), + }); + const { modules, errors } = loadPolicy(root, []); + if (errors.length > 0) { assert.fail('policy load should succeed: ' + errors.join(', ')); } + const classErrors = []; + const classified = classifyFiles(['src/alpha/index.ts', 'src/alpha/internal/helper.ts'], modules, classErrors); + assert.deepEqual(classErrors, []); + assert.equal(classified.find(c => c.file === 'src/alpha/index.ts').role, 'composition', + 'a specific earlier role wins over the remainder'); + assert.equal(classified.find(c => c.file === 'src/alpha/internal/helper.ts').role, 'application', + 'the remainder claims what no earlier role claimed'); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('TRUSTED-KERNEL-001 controlled mutation: a remainder role that is not last fails', () => { + const root = tmpDir(); + writeTree(root, { + 'src/alpha/index.ts': 'export const x = 1;', + 'docs/testing/architecture-modules.json': JSON.stringify({ version: 1, scope: { roots: ['src'] }, modules: [ + { id: 'MOD-ALPHA', source: { include: ['src/**'], exclude: [] }, publicEntrypoints: ['src/alpha/index.ts'], mayDependOn: [], productCapabilities: ['CAP-1'], roles: [ + { role: 'application', include: ['**'] }, + { role: 'composition', include: ['src/alpha/index.ts'] }, + ]}, + ]}), + 'docs/testing/main-capability-coverage.json': JSON.stringify({ version: 1, capabilities: [{ id: 'CAP-1' }] }), + }); + const { modules, errors } = loadPolicy(root, []); + if (errors.length > 0) { assert.fail('policy load should succeed: ' + errors.join(', ')); } + const classErrors = []; + classifyFiles(['src/alpha/index.ts'], modules, classErrors); + assert.ok(classErrors.some(e => e.includes('remainder role')), JSON.stringify(classErrors)); + fs.rmSync(root, { recursive: true, force: true }); +}); + // ── mutation 4: illegal cross-module import ────────────────────────── test('TRUSTED-KERNEL-001 controlled mutation: an undeclared cross-module edge fails', () => {