yay-layer 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONSTITUTION.md +55 -0
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/yay.js +3550 -0
- package/package.json +55 -0
- package/src/adopt.js +181 -0
- package/src/adversary.js +119 -0
- package/src/analyze.js +270 -0
- package/src/assurance.js +122 -0
- package/src/attest.js +216 -0
- package/src/capability.js +59 -0
- package/src/constitution.js +162 -0
- package/src/coverage.js +77 -0
- package/src/crypto.js +78 -0
- package/src/dashboard.js +463 -0
- package/src/durable.js +152 -0
- package/src/e2e.js +67 -0
- package/src/extract.js +179 -0
- package/src/foundation.js +143 -0
- package/src/gate.js +252 -0
- package/src/grants.js +249 -0
- package/src/history.js +77 -0
- package/src/ids.js +40 -0
- package/src/manifest.js +303 -0
- package/src/map.js +1742 -0
- package/src/mutate.js +192 -0
- package/src/objects.js +31 -0
- package/src/phone.js +188 -0
- package/src/plan.js +141 -0
- package/src/policy.js +0 -0
- package/src/predicate.js +143 -0
- package/src/prove.js +876 -0
- package/src/ratify.js +28 -0
- package/src/record.js +30 -0
- package/src/reverify.js +212 -0
- package/src/roster.js +117 -0
- package/src/signer-page.js +603 -0
- package/src/specdiff.js +69 -0
- package/src/tags.js +67 -0
- package/src/testrun.js +41 -0
- package/src/util.js +234 -0
- package/src/vendor/recovery.js +217 -0
- package/src/vendor/tweetnacl.min.js +1 -0
- package/src/verify.js +560 -0
- package/standard/STANDARD.md +135 -0
package/src/specdiff.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Compute what changed in a Cell's SPEC versus its last committed (HEAD) version,
|
|
3
|
+
// so the phone can show a real diff before the human approves. Read-only + best-
|
|
4
|
+
// effort: outside a git repo, or for a brand-new/unchanged spec, it returns null.
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { MARK_BEGIN, MARK_END } = require('./util');
|
|
8
|
+
|
|
9
|
+
// Readable spec-field lines for a cell from raw file content (strips the // or # lead).
|
|
10
|
+
function specLinesFromContent(content, cellId) {
|
|
11
|
+
const lines = String(content).split(/\r?\n/);
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
const b = lines[i].match(MARK_BEGIN);
|
|
14
|
+
if (b && b[1] === cellId) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
17
|
+
if (MARK_END.test(lines[j])) return out;
|
|
18
|
+
out.push(lines[j].replace(/^\s*\/\/+\s?/, '').replace(/^\s*#\s?/, '').replace(/\s+$/, ''));
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null; // cell not present in this version (i.e. it's new)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Same, from the manifest cell's `normalized` block (markers + inner lines).
|
|
27
|
+
function normalizedToSpecLines(normalized) {
|
|
28
|
+
return String(normalized || '').split('\n')
|
|
29
|
+
.filter((l) => !MARK_BEGIN.test(l) && !MARK_END.test(l))
|
|
30
|
+
.map((l) => l.replace(/^\s*\/\/+\s?/, '').replace(/^\s*#\s?/, '').replace(/\s+$/, ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The committed (HEAD) content of a file, or null if not in git / not tracked.
|
|
34
|
+
// Uses git's own `--show-prefix` (repo-root → file's dir) instead of path.relative,
|
|
35
|
+
// which would break when the toplevel resolves symlinks (e.g. macOS /var → /private/var).
|
|
36
|
+
function gitHeadContent(root, absFile) {
|
|
37
|
+
try {
|
|
38
|
+
const cp = require('child_process'), opt = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
|
|
39
|
+
const dir = path.dirname(absFile), base = path.basename(absFile);
|
|
40
|
+
const prefix = cp.execFileSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], opt).trim();
|
|
41
|
+
return cp.execFileSync('git', ['-C', dir, 'show', 'HEAD:' + prefix + base], opt);
|
|
42
|
+
} catch (_) { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Minimal LCS line diff → [{t:' '|'-'|'+', text}]; null if identical or no previous.
|
|
46
|
+
function lineDiff(oldL, newL) {
|
|
47
|
+
if (!oldL) return null;
|
|
48
|
+
const a = oldL, b = newL, n = a.length, m = b.length;
|
|
49
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
50
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
51
|
+
const out = []; let i = 0, j = 0;
|
|
52
|
+
while (i < n && j < m) { if (a[i] === b[j]) { out.push({ t: ' ', text: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ t: '-', text: a[i++] }); else out.push({ t: '+', text: b[j++] }); }
|
|
53
|
+
while (i < n) out.push({ t: '-', text: a[i++] });
|
|
54
|
+
while (j < m) out.push({ t: '+', text: b[j++] });
|
|
55
|
+
return out.some((d) => d.t !== ' ') ? out : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The change to a cell's spec vs its last committed version (for phone review).
|
|
59
|
+
function specDiffForCell(root, cell) {
|
|
60
|
+
try {
|
|
61
|
+
const head = gitHeadContent(root, path.resolve(root, cell.file));
|
|
62
|
+
if (head == null) return null;
|
|
63
|
+
// manifest cells carry the block as `specBlock` (extract calls it `normalized`).
|
|
64
|
+
const current = normalizedToSpecLines(cell.specBlock || cell.normalized);
|
|
65
|
+
return lineDiff(specLinesFromContent(head, cell.id), current);
|
|
66
|
+
} catch (_) { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { specLinesFromContent, normalizedToSpecLines, gitHeadContent, lineDiff, specDiffForCell };
|
package/src/tags.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Brief TAGS — a small, project-chosen vocabulary that every Brief is tagged with, so the
|
|
3
|
+
// history of what was built can be sorted by concern over time. Neutral by default in the
|
|
4
|
+
// sense that the pool is the project's choice; once a pool is set, tagging is required on
|
|
5
|
+
// each Brief (Standard §5). Tags ride inside the signed Brief, so they are attributed and
|
|
6
|
+
// tamper-evident like the rest of the approval.
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
// Six curated, mutually distinct starter sets. Pick one at `yay init`; switch or extend
|
|
11
|
+
// later with `yay tags`. The `tags` list is the authoritative active pool once chosen.
|
|
12
|
+
const TAG_SETS = [
|
|
13
|
+
{ id: 'technical', name: 'Technical / code type', desc: 'By the kind of code — HTML, CSS, API, Database…',
|
|
14
|
+
tags: ['HTML', 'CSS', 'JavaScript', 'Frontend', 'Backend', 'API', 'Database', 'Auth', 'Validation', 'Testing', 'Config', 'Build', 'DevOps', 'Utilities'] },
|
|
15
|
+
{ id: 'responsibility', name: 'Responsibility', desc: 'By what the code is responsible for — UI, State, Security…',
|
|
16
|
+
tags: ['UI', 'State', 'Events', 'Routing', 'Business Logic', 'Data Access', 'API', 'Persistence', 'Security', 'Error Handling', 'Logging', 'Caching', 'Performance', 'Testing', 'Config', 'Infrastructure'] },
|
|
17
|
+
{ id: 'component', name: 'Component kind', desc: 'By the kind of unit — Component, Service, Model, Worker…',
|
|
18
|
+
tags: ['Component', 'Hook', 'Service', 'Controller', 'Model', 'Repository', 'Middleware', 'Validator', 'Serializer', 'Adapter', 'Provider', 'Factory', 'Utility', 'Worker', 'Job', 'Command', 'Event', 'Listener', 'Test', 'Fixture'] },
|
|
19
|
+
{ id: 'layer', name: 'Layer', desc: 'By architectural layer — Presentation, Domain, Data, Infra…',
|
|
20
|
+
tags: ['Presentation', 'Interaction', 'Application', 'Domain', 'Service', 'Integration', 'Data Access', 'Persistence', 'Infrastructure', 'Security'] },
|
|
21
|
+
{ id: 'area', name: 'App area', desc: 'By where it lives in the product — Header, Settings, Auth…',
|
|
22
|
+
tags: ['App Shell', 'Header', 'Navigation', 'Sidebar', 'Toolbar', 'Main Content', 'Dashboard', 'Page', 'Form', 'Modal', 'Search', 'Notifications', 'Profile', 'Settings', 'Auth', 'Admin', 'Footer', 'Onboarding'] },
|
|
23
|
+
{ id: 'product', name: 'Product system', desc: 'By product subsystem — Experience, Identity, Data, Integration…',
|
|
24
|
+
tags: ['Experience', 'Navigation', 'Interaction', 'Content', 'Application', 'Domain', 'Identity', 'Data', 'Communication', 'Integration', 'Background Jobs', 'Observability', 'Infrastructure'] },
|
|
25
|
+
];
|
|
26
|
+
const setById = (id) => TAG_SETS.find((s) => s.id === id) || null;
|
|
27
|
+
|
|
28
|
+
// "Custom" isn't a preset — it seeds blank placeholders the user relabels afterwards
|
|
29
|
+
// (in the Dashboard Tags tab, or by editing .yaylayer/tags.json directly).
|
|
30
|
+
const CUSTOM_SEED = ['Custom 1', 'Custom 2', 'Custom 3', 'Custom 4'];
|
|
31
|
+
|
|
32
|
+
function tagsPath(p) { return path.join(path.dirname(p.config), 'tags.json'); }
|
|
33
|
+
function loadTags(p) {
|
|
34
|
+
try { const o = JSON.parse(fs.readFileSync(tagsPath(p), 'utf8')); return (o && Array.isArray(o.tags)) ? o : null; }
|
|
35
|
+
catch (_) { return null; }
|
|
36
|
+
}
|
|
37
|
+
function saveTags(p, obj) { fs.writeFileSync(tagsPath(p), JSON.stringify(obj, null, 2) + '\n'); }
|
|
38
|
+
|
|
39
|
+
// Case/space-insensitive match; returns the pool's canonical casing when a tag is in-pool.
|
|
40
|
+
const norm = (t) => String(t == null ? '' : t).trim().toLowerCase().replace(/\s+/g, ' ');
|
|
41
|
+
function canonicalTag(pool, t) { const n = norm(t); const hit = (pool || []).find((x) => norm(x) === n); return hit || String(t).trim(); }
|
|
42
|
+
function isKnown(pool, t) { const n = norm(t); return (pool || []).some((x) => norm(x) === n); }
|
|
43
|
+
// Parse a comma/space separated tag string into a de-duped, canonical list.
|
|
44
|
+
function parseTags(pool, raw) {
|
|
45
|
+
const seen = new Set(); const out = [];
|
|
46
|
+
String(raw || '').split(/[,\n]/).map((s) => s.trim()).filter(Boolean).forEach((t) => {
|
|
47
|
+
const c = canonicalTag(pool, t); const k = norm(c);
|
|
48
|
+
if (!seen.has(k)) { seen.add(k); out.push(c); }
|
|
49
|
+
});
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function unknownTags(pool, tags) { return (tags || []).filter((t) => !isKnown(pool, t)); }
|
|
53
|
+
|
|
54
|
+
// Is the tag plan FINISHED — ready to tag signed Briefs with? A finished plan has no
|
|
55
|
+
// unrelabeled "Custom N" placeholders and at least MIN_PLAN_TAGS unique tags. The AI
|
|
56
|
+
// picks a Brief's tags FROM the human's plan, so an unfinished plan poisons every
|
|
57
|
+
// downstream choice — signing is refused until the plan is finished.
|
|
58
|
+
const MIN_PLAN_TAGS = 5;
|
|
59
|
+
const isPlaceholder = (t) => /^custom\s*\d+$/i.test(String(t || '').trim());
|
|
60
|
+
function planStatus(tagCfg) {
|
|
61
|
+
if (!tagCfg || !Array.isArray(tagCfg.tags)) return { exists: false, ok: false, placeholders: [], unique: 0 };
|
|
62
|
+
const placeholders = tagCfg.tags.filter(isPlaceholder);
|
|
63
|
+
const unique = new Set(tagCfg.tags.filter((t) => !isPlaceholder(t)).map(norm)).size;
|
|
64
|
+
return { exists: true, ok: placeholders.length === 0 && unique >= MIN_PLAN_TAGS, placeholders, unique };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { TAG_SETS, setById, CUSTOM_SEED, MIN_PLAN_TAGS, tagsPath, loadTags, saveTags, canonicalTag, isKnown, parseTags, unknownTags, norm, isPlaceholder, planStatus };
|
package/src/testrun.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Run the PROJECT's own test suite (not the per-Cell prover) so the dashboard and
|
|
3
|
+
// the gate can show a full picture: Cell colours + prover + your real tests.
|
|
4
|
+
// Best-effort + bounded; command resolution prefers an explicit setting.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const cp = require('child_process');
|
|
9
|
+
|
|
10
|
+
// The command to run: --test flag > config.test > package.json "test" script.
|
|
11
|
+
function resolveTestCmd(root, config, flags) {
|
|
12
|
+
if (flags && flags.test && flags.test !== true) return String(flags.test);
|
|
13
|
+
if (config && config.test) return String(config.test);
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
16
|
+
const t = pkg.scripts && pkg.scripts.test;
|
|
17
|
+
if (t && !/no test specified/i.test(t)) return 'npm test'; // real test script present
|
|
18
|
+
} catch (_) {}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Run `cmd` in the repo root, capturing combined output + exit code (bounded).
|
|
23
|
+
function runTests(root, cmd, timeoutMs) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
if (!cmd) return resolve({ configured: false, cmd: null, ok: false, code: null, output: 'No test command configured. Add a "test" script to package.json, set "test" in .yaylayer/config.json, or pass --test "…".', ms: 0 });
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
cp.exec(cmd, { cwd: root, timeout: timeoutMs || 300000, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
|
|
28
|
+
const combined = ((stdout || '') + (stderr || ''));
|
|
29
|
+
resolve({
|
|
30
|
+
configured: true, cmd,
|
|
31
|
+
ok: !err,
|
|
32
|
+
code: err ? (err.code == null ? 1 : err.code) : 0,
|
|
33
|
+
timedOut: !!(err && err.killed),
|
|
34
|
+
output: combined.slice(-20000) || (err ? String(err.message) : ''),
|
|
35
|
+
ms: Date.now() - started,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { resolveTestCmd, runTests };
|
package/src/util.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared helpers: paths, canonical JSON, file walking, terminal color, marker constants.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// The unique comment markers that delimit a YayLayer spec block.
|
|
8
|
+
// `YAY` = "this is YayLayer"; the id (e.g. C-040) names the Cell.
|
|
9
|
+
const MARK_BEGIN = /∷YAY⟨\s*([A-Za-z0-9._-]+)\s*⟩/;
|
|
10
|
+
const MARK_END = /∷YAY-END⟨\s*([A-Za-z0-9._-]+)\s*⟩/;
|
|
11
|
+
|
|
12
|
+
const YAY_DIR = '.yaylayer';
|
|
13
|
+
const CONFIG = 'config.json';
|
|
14
|
+
const LOCK = 'lock.json';
|
|
15
|
+
const KEYS = 'keys';
|
|
16
|
+
|
|
17
|
+
function repoRoot(start) {
|
|
18
|
+
let dir = path.resolve(start || process.cwd());
|
|
19
|
+
for (;;) {
|
|
20
|
+
if (fs.existsSync(path.join(dir, YAY_DIR)) || fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
21
|
+
const up = path.dirname(dir);
|
|
22
|
+
if (up === dir) return path.resolve(start || process.cwd());
|
|
23
|
+
dir = up;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const paths = (root) => ({
|
|
28
|
+
root,
|
|
29
|
+
yay: path.join(root, YAY_DIR),
|
|
30
|
+
config: path.join(root, YAY_DIR, CONFIG),
|
|
31
|
+
lock: path.join(root, YAY_DIR, LOCK),
|
|
32
|
+
keys: path.join(root, YAY_DIR, KEYS),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function readJSON(file, fallback) {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
37
|
+
catch (_) { return fallback; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeJSON(file, obj) {
|
|
41
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
42
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Deterministic serialization so a hash/signature is stable regardless of key order.
|
|
46
|
+
function canonical(obj) {
|
|
47
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
48
|
+
if (Array.isArray(obj)) return '[' + obj.map(canonical).join(',') + ']';
|
|
49
|
+
const keys = Object.keys(obj).sort();
|
|
50
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(obj[k])).join(',') + '}';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A roster entry is one identity that may hold several keys (local + phone …).
|
|
54
|
+
// Normalize any stored shape to a flat list of base64 public keys.
|
|
55
|
+
// legacy string → ["pub"]
|
|
56
|
+
// ["pub", …] → as-is
|
|
57
|
+
// [{pub,kind,…}] → [pub, …]
|
|
58
|
+
function pubKeysOf(entry) {
|
|
59
|
+
if (!entry) return [];
|
|
60
|
+
if (typeof entry === 'string') return [entry];
|
|
61
|
+
if (Array.isArray(entry)) return entry.map((k) => (typeof k === 'string' ? k : k && k.pub)).filter(Boolean);
|
|
62
|
+
if (entry.pub) return [entry.pub];
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.yaylayer', 'docs', 'dist', 'build', 'coverage']);
|
|
67
|
+
// Every extension we scan for spec blocks. Spec fields are comment-agnostic (see
|
|
68
|
+
// extract.parseSpec), so any of these can carry a spec block. Verification depth
|
|
69
|
+
// then depends on the language's tier (see below).
|
|
70
|
+
const CODE_EXT = new Set([
|
|
71
|
+
// JS/TS — fully analyzed + behaviourally proven
|
|
72
|
+
'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx',
|
|
73
|
+
// brace-family (C-style) — spec-mirror + sign + gate + map, capped at Yellow
|
|
74
|
+
'.cs', '.sol', '.rs', '.go', '.java', '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hh',
|
|
75
|
+
'.kt', '.kts', '.swift', '.php', '.scala', '.dart', '.pl', '.pm', '.sh', '.bash',
|
|
76
|
+
// indentation-scoped
|
|
77
|
+
'.py', '.pyw',
|
|
78
|
+
// def…end-scoped
|
|
79
|
+
'.rb', '.ex', '.exs',
|
|
80
|
+
// shallow (marker blocks only, no unit analysis)
|
|
81
|
+
'.css', '.html',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// Body-delimiting FAMILY for a file — selects how the unit body is grabbed:
|
|
85
|
+
// js → JS/TS declaration + brace matcher (full analysis + proof)
|
|
86
|
+
// brace → generic C-family brace matcher (Go, Java, C/C++, Kotlin, Swift, …)
|
|
87
|
+
// python→ indentation
|
|
88
|
+
// ruby → def…end (Ruby, Elixir)
|
|
89
|
+
// css/html/other → shallow (no unit body)
|
|
90
|
+
const BRACE_EXT = new Set(['.cs', '.sol', '.rs', '.go', '.java', '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hh', '.kt', '.kts', '.swift', '.php', '.scala', '.dart', '.pl', '.pm', '.sh', '.bash']);
|
|
91
|
+
function langOf(file) {
|
|
92
|
+
const e = path.extname(String(file)).toLowerCase();
|
|
93
|
+
if (['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'].includes(e)) return 'js';
|
|
94
|
+
if (e === '.py' || e === '.pyw') return 'python';
|
|
95
|
+
if (e === '.rb' || e === '.ex' || e === '.exs') return 'ruby';
|
|
96
|
+
if (BRACE_EXT.has(e)) return 'brace';
|
|
97
|
+
if (e === '.css') return 'css';
|
|
98
|
+
if (e === '.html') return 'html';
|
|
99
|
+
return 'other';
|
|
100
|
+
}
|
|
101
|
+
// The SPECIFIC language (not the body-grabbing family) — so per-language effect nets and the
|
|
102
|
+
// Ruby/PHP provers can tell php/solidity/rust/csharp apart (all family 'brace') and ruby apart
|
|
103
|
+
// from elixir (both family 'ruby'). Family selects how a unit body is grabbed; this selects
|
|
104
|
+
// which language's effect idioms to police.
|
|
105
|
+
const LANG_NAME = {
|
|
106
|
+
'.js': 'js', '.jsx': 'js', '.mjs': 'js', '.cjs': 'js', '.ts': 'ts', '.tsx': 'ts',
|
|
107
|
+
'.py': 'python', '.pyw': 'python', '.rb': 'ruby', '.ex': 'elixir', '.exs': 'elixir',
|
|
108
|
+
'.php': 'php', '.sol': 'solidity', '.rs': 'rust', '.cs': 'csharp', '.go': 'go', '.java': 'java',
|
|
109
|
+
'.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', '.hh': 'cpp',
|
|
110
|
+
'.kt': 'kotlin', '.kts': 'kotlin', '.swift': 'swift', '.scala': 'scala', '.dart': 'dart',
|
|
111
|
+
'.pl': 'perl', '.pm': 'perl', '.sh': 'shell', '.bash': 'shell',
|
|
112
|
+
};
|
|
113
|
+
function langNameOf(file) { return LANG_NAME[path.extname(String(file)).toLowerCase()] || 'other'; }
|
|
114
|
+
// Normalise a language STRING (a cell's extension-derived lang, or a `lang:` spec override) to the
|
|
115
|
+
// canonical name the effect nets / provers key on — so 'rb'|'ruby', 'cs'|'c#'|'csharp' all agree.
|
|
116
|
+
function normLangName(l) {
|
|
117
|
+
l = String(l || '').toLowerCase().trim();
|
|
118
|
+
const m = { jsx: 'js', mjs: 'js', cjs: 'js', tsx: 'ts', py: 'python', pyw: 'python', rb: 'ruby', sol: 'solidity', rs: 'rust', cs: 'csharp', 'c#': 'csharp' };
|
|
119
|
+
return m[l] || l;
|
|
120
|
+
}
|
|
121
|
+
// The comment lead used when `yay adopt` scaffolds a block: '#' for hash-comment
|
|
122
|
+
// languages, '//' otherwise. (Reading is comment-agnostic; only writing needs this.)
|
|
123
|
+
const HASH_EXT = new Set(['.py', '.pyw', '.rb', '.ex', '.exs', '.pl', '.pm', '.sh', '.bash']);
|
|
124
|
+
function commentLeadOf(file) { return HASH_EXT.has(path.extname(String(file)).toLowerCase()) ? '#' : '//'; }
|
|
125
|
+
|
|
126
|
+
// The only languages YayLayer currently analyzes (AST) and can behaviourally
|
|
127
|
+
// prove. Everything else is signed-but-unverified. `lang` here is the manifest's
|
|
128
|
+
// per-Cell value (a file extension slice like 'ts', or a spec `lang:` override).
|
|
129
|
+
const JS_LANGS = new Set(['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'javascript', 'typescript']);
|
|
130
|
+
function isJsLang(l) { return JS_LANGS.has(String(l || '').toLowerCase()); }
|
|
131
|
+
|
|
132
|
+
function walk(dir, out = []) {
|
|
133
|
+
let entries;
|
|
134
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; }
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
if (e.name.startsWith('.') && e.name !== '.') continue;
|
|
137
|
+
const full = path.join(dir, e.name);
|
|
138
|
+
if (e.isDirectory()) {
|
|
139
|
+
if (!SKIP_DIRS.has(e.name)) walk(full, out);
|
|
140
|
+
} else if (CODE_EXT.has(path.extname(e.name))) {
|
|
141
|
+
out.push(full);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Minimal ANSI color (auto-disabled when not a TTY or NO_COLOR set).
|
|
148
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
149
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
150
|
+
const c = {
|
|
151
|
+
green: paint('32'), yellow: paint('33'), red: paint('31'),
|
|
152
|
+
gray: paint('90'), bold: paint('1'), accent: paint('35'), dim: paint('2'),
|
|
153
|
+
pink: paint('95'),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// State → glyph + colorizer, used across verify/map output.
|
|
157
|
+
// PINK = code with NO formal specification at all — untracked, never described
|
|
158
|
+
// or signed. The most dangerous state: unknown territory where silent bugs hide.
|
|
159
|
+
const STATE = {
|
|
160
|
+
GREEN: { glyph: '●', color: c.green, label: 'GREEN' },
|
|
161
|
+
YELLOW: { glyph: '●', color: c.yellow, label: 'YELLOW' },
|
|
162
|
+
RED: { glyph: '●', color: c.red, label: 'RED' },
|
|
163
|
+
UNSIGNED: { glyph: '○', color: c.gray, label: 'UNSIGNED' },
|
|
164
|
+
PINK: { glyph: '◆', color: c.pink, label: 'PINK' },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Top-level IMPERATIVE code in a non-JS file (Python/Ruby) — the "sneak a DO-THIS line
|
|
168
|
+
// at module scope" shape that runs at import time. JS gets this from the AST; non-JS
|
|
169
|
+
// langs had no equivalent, so a bare `os.system(...)` at column 0 slipped past the Pink
|
|
170
|
+
// net entirely. Conservative on purpose (mirrors JS looseTopLevel): flag indent-0 bare
|
|
171
|
+
// CALLS (`foo(` / `foo.bar(`) and control-flow starters, but skip declarations, imports,
|
|
172
|
+
// comments, spec markers, the `if __name__` main-guard, and assignments (module
|
|
173
|
+
// constants/wiring — same carve-out JS makes). Returns 1-based line numbers.
|
|
174
|
+
// A NARROW, high-confidence set of "runs a dangerous side effect AT IMPORT" tokens —
|
|
175
|
+
// network exfiltration + process execution. Used ONLY to un-exempt a top-level
|
|
176
|
+
// ASSIGNMENT (`const X = …`, `X = …`), which is otherwise treated as a module constant.
|
|
177
|
+
// Deliberately excludes ubiquitous benign wiring (require, document/window, Date.now)
|
|
178
|
+
// so a normal codebase isn't drowned in false Pinks: the target is `LEAK = fetch(evil)`
|
|
179
|
+
// / `X = requests.get(url)` at module scope, not every constant. (Distinct from the
|
|
180
|
+
// broad purity net in verify.js, which polices effects INSIDE a Cell's body.)
|
|
181
|
+
const LOADTIME_JS = [/\bfetch\s*\(/, /\bXMLHttpRequest\b/, /\baxios\b/, /\bWebSocket\b/, /\bnavigator\s*\.\s*sendBeacon\b/, /\.\s*exec(?:Sync|File|FileSync)?\s*\(/, /\.\s*spawn(?:Sync)?\s*\(/, /\bchild_process\b/, /\bimport\s*\(/];
|
|
182
|
+
const LOADTIME_PY = [/\brequests\s*\./, /\burllib\b/, /\bhttpx\s*\./, /\bsocket\s*\./, /\bos\s*\.\s*system\s*\(/, /\bos\s*\.\s*popen\s*\(/, /\bsubprocess\s*\./, /\b__import__\s*\(/, /\beval\s*\(/, /\bexec\s*\(/];
|
|
183
|
+
function hasLoadTimeEffect(text, family) {
|
|
184
|
+
const set = /^py/.test(String(family || '')) ? LOADTIME_PY : LOADTIME_JS;
|
|
185
|
+
const s = String(text || '');
|
|
186
|
+
return set.some((re) => re.test(s));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Top-level (executable-at-load) statements in a PHP file — used ONLY by the PHP prover as a
|
|
190
|
+
// safety gate (never eval a file that runs code outside function/class bodies). Conservative: at
|
|
191
|
+
// brace-depth 0, anything that isn't a declaration / tag / comment is flagged, so we refuse to
|
|
192
|
+
// prove rather than risk executing top-level side effects. (Not wired into the gate's Pink net —
|
|
193
|
+
// that stays family-based; this is opt-in via family 'php' from the adapter.)
|
|
194
|
+
function loosePhp(lines) {
|
|
195
|
+
const out = []; let depth = 0;
|
|
196
|
+
const DECL = /^(?:<\?php|<\?=|<\?|\?>|namespace\b|use\b|declare\b|abstract\b|final\b|interface\b|trait\b|enum\b|class\b|function\b|const\b|require\b|require_once\b|include\b|include_once\b|\/\/|#|\/\*|\*)/;
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
const raw = lines[i]; const t = raw.trim();
|
|
199
|
+
if (t && depth <= 0 && !DECL.test(t) && !/∷YAY/.test(t) && t !== '{' && t !== '}') out.push(i + 1);
|
|
200
|
+
depth += (raw.match(/\{/g) || []).length - (raw.match(/\}/g) || []).length;
|
|
201
|
+
if (depth < 0) depth = 0;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
function looseTopLevelNonJs(lines, family) {
|
|
206
|
+
if (family === 'php') return loosePhp(lines);
|
|
207
|
+
if (family !== 'python' && family !== 'ruby') return [];
|
|
208
|
+
const out = [];
|
|
209
|
+
const CALL = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*\(/;
|
|
210
|
+
const CTRL = /^(?:if|for|while|with|try|unless|begin|case|loop)\b/;
|
|
211
|
+
const DECL = /^(?:def|class|async|module|import|from|require|require_relative|include|extend|attr_[a-z]+|@|#|"""|''')/;
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const raw = lines[i];
|
|
214
|
+
if (/^\s/.test(raw)) continue; // indented → inside some block
|
|
215
|
+
const t = raw.trim();
|
|
216
|
+
if (!t) continue;
|
|
217
|
+
if (/∷YAY/.test(t) || DECL.test(t)) continue;
|
|
218
|
+
if (/^if\s+__name__/.test(t)) continue; // standard Python entry guard (dead on import)
|
|
219
|
+
const eq = t.search(/[^=!<>]=[^=]/); // a real single '=' (assignment), not ==/!=/<=/>=
|
|
220
|
+
const par = t.indexOf('(');
|
|
221
|
+
if (eq >= 0 && (par < 0 || eq < par)) { // assignment → module constant/wiring…
|
|
222
|
+
if (hasLoadTimeEffect(t, family)) out.push(i + 1); // …UNLESS its RHS exfiltrates/execs at import
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (CALL.test(t) || CTRL.test(t)) out.push(i + 1);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
MARK_BEGIN, MARK_END, YAY_DIR,
|
|
232
|
+
repoRoot, paths, readJSON, writeJSON, canonical, pubKeysOf, walk, c, STATE,
|
|
233
|
+
langOf, langNameOf, normLangName, isJsLang, commentLeadOf, looseTopLevelNonJs, hasLoadTimeEffect,
|
|
234
|
+
};
|