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.
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+ // Build the manifest: scan a tree, extract every Cell (marker blocks), and use
3
+ // the AST analyzer to (a) attach the exact unit a Cell governs and (b) find
4
+ // UNTRACKED units — named code with no spec block → PINK. Falls back to a shallow
5
+ // regex for files the parser can't handle (e.g. TypeScript) so nothing is silently
6
+ // dropped.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { walk, repoRoot, langOf, langNameOf, normLangName, isJsLang, looseTopLevelNonJs } = require('./util');
11
+ const { sha256 } = require('./crypto');
12
+ const { extractFile } = require('./extract');
13
+ const { analyze, nearestUnitAfter } = require('./analyze');
14
+
15
+ // Names a bare `foo()` call can resolve to without a project definition — JS/DOM/
16
+ // Node builtins + common globals. Anything called but neither defined nor here is a
17
+ // dangling reference. (Kept generous to avoid false positives; extend as needed.)
18
+ const GLOBALS = new Set([
19
+ 'Array', 'Object', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt', 'Function', 'Math', 'JSON', 'Date', 'RegExp',
20
+ 'Error', 'TypeError', 'RangeError', 'SyntaxError', 'Promise', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Proxy', 'Reflect',
21
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
22
+ 'structuredClone', 'queueMicrotask', 'eval',
23
+ 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'setImmediate', 'requestAnimationFrame', 'cancelAnimationFrame', 'fetch',
24
+ 'window', 'document', 'console', 'alert', 'confirm', 'prompt', 'localStorage', 'sessionStorage', 'navigator', 'location', 'history',
25
+ 'atob', 'btoa', 'getComputedStyle', 'matchMedia', 'FormData', 'Headers', 'Request', 'Response', 'URL', 'URLSearchParams',
26
+ 'Blob', 'File', 'FileReader', 'Image', 'Audio', 'Worker', 'WebSocket', 'EventSource',
27
+ 'IntersectionObserver', 'MutationObserver', 'ResizeObserver', 'crypto', 'CustomEvent', 'Event',
28
+ 'require', 'process', 'Buffer', '__dirname', '__filename', 'module', 'exports', 'global', 'globalThis',
29
+ '$', '_',
30
+ ]);
31
+
32
+ const JS_LIKE = /\.(js|jsx|mjs|cjs|ts|tsx)$/;
33
+
34
+ // Fallback (parse failed): top-level `function name(` / `const name = (` only.
35
+ const COVER_FN = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)|^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z0-9_$]+\s*=>)/;
36
+
37
+ function untrackedRegex(file, rel, coveredNames, out) {
38
+ let lines;
39
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch (_) { return; }
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const m = lines[i].match(COVER_FN);
42
+ if (!m) continue;
43
+ const name = m[1] || m[2];
44
+ if (!name || coveredNames.has(name)) continue;
45
+ if (/∷YAY-END|∷YAY⟨/.test(lines[i - 1] || '')) continue;
46
+ out.push({ name, file: rel, line: i + 1, kind: 'function', lang: path.extname(file).slice(1) });
47
+ }
48
+ }
49
+
50
+ // Conservative, keyword-led unit detection for non-JS languages, used only to find
51
+ // UNTRACKED (un-specced) units → PINK. Deliberately under-detects rather than risk a
52
+ // FALSE Pink (which would wrongly block the gate): KW_FN is anchored on an unambiguous
53
+ // declaration keyword (fn/def/func/fun/function/sub), and MOD_METHOD requires an access
54
+ // modifier — neither matches control-flow (if/for/while/…). Languages with no safe
55
+ // pattern (plain C, Dart, bare shell fns) are simply not scanned here (safe under-detect).
56
+ // Comment lines are skipped so commented-out code and spec fields never register.
57
+ const KW_FN = /^\s*(?:pub\s+|export\s+|public\s+|private\s+|protected\s+|internal\s+|static\s+|final\s+|open\s+|override\s+|async\s+)*(?:fn|def|defp|func|fun|function|sub)\s+(?:self\.)?([A-Za-z_][A-Za-z0-9_]*)/;
58
+ const MOD_METHOD = /(?:^|\s)(?:public|private|protected|internal)(?:\s+(?:static|virtual|override|sealed|abstract|async|partial|new|readonly|unsafe|extern|final))*\s+[A-Za-z_][A-Za-z0-9_<>[\],.?]*\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]*>)?\s*\(/;
59
+ const LANG_UNTRACKED = {
60
+ python: [KW_FN],
61
+ ruby: [KW_FN],
62
+ brace: [KW_FN, MOD_METHOD],
63
+ };
64
+ function untrackedLangRegex(file, rel, family, coveredNames, out) {
65
+ const pats = LANG_UNTRACKED[family];
66
+ if (!pats) return;
67
+ let lines;
68
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch (_) { return; }
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const t = lines[i].trim();
71
+ // skip blanks, comments (all four use // or #), block-comment lines, and spec fields
72
+ if (!t || t.startsWith('//') || t.startsWith('#') || t.startsWith('/*') || t.startsWith('*')) continue;
73
+ for (const re of pats) {
74
+ const m = lines[i].match(re);
75
+ if (!m) continue;
76
+ const name = m[1];
77
+ if (!name || coveredNames.has(name)) break;
78
+ if (/∷YAY-END|∷YAY⟨/.test(lines[i - 1] || '')) break;
79
+ out.push({ name, file: rel, line: i + 1, kind: 'function', lang: path.extname(file).slice(1) });
80
+ break;
81
+ }
82
+ }
83
+ }
84
+
85
+ const baseName = (f) => String(f).split('/').pop();
86
+ // A module is named by its exposed namespace (global.X / export / module.exports),
87
+ // else by its filename. Works for ES modules, CommonJS, UMD/IIFE, and plain scripts.
88
+ function moduleNameOf(ana, rel) { return (ana && ana.namespace && ana.namespace.name) || baseName(rel); }
89
+ // A unit's sub-group: its class/object container, else Public API vs Internal.
90
+ function groupOf(unit, ana) {
91
+ if (unit.container) return unit.container;
92
+ const short = String(unit.name).split('.').pop();
93
+ return (ana && ana.namespace && ana.namespace.publicNames && ana.namespace.publicNames.has(short)) ? 'Public API' : 'Internal';
94
+ }
95
+
96
+ // Files the scanner must NOT treat as source: the generated map output (it embeds
97
+ // spec text → would self-report a missing marker), plus anything in .yaylayerignore
98
+ // (gitignore-ish: basenames, path tails, `*` globs, `dir/` prefixes).
99
+ function makeIgnore(root) {
100
+ const patterns = ['yay-layer-map.html', 'yay-layer-map.*.html'];
101
+ try {
102
+ for (const line of fs.readFileSync(path.join(root, '.yaylayerignore'), 'utf8').split(/\r?\n/)) {
103
+ const p = line.trim(); if (p && !p.startsWith('#')) patterns.push(p);
104
+ }
105
+ } catch (_) {}
106
+ const res = patterns.map((p) => {
107
+ const g = p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
108
+ return new RegExp(p.endsWith('/') ? '(^|/)' + g : '(^|/)' + g + '$');
109
+ });
110
+ return (rel) => res.some((re) => re.test(rel));
111
+ }
112
+
113
+ function buildManifest(targetDir) {
114
+ const root = repoRoot(targetDir);
115
+ const ignore = makeIgnore(root);
116
+ const scanned = walk(path.resolve(targetDir || root));
117
+ const files = scanned.filter((f) => !ignore(path.relative(root, f)));
118
+ // Files HIDDEN by .yaylayerignore that are SOURCE CODE — a bare `.yaylayerignore`
119
+ // line can otherwise remove code from the gate's view entirely (green while a file
120
+ // holds anything). These become gate-blocking Pink below unless an OWNER-SIGNED
121
+ // policy `ignore: source` rule whitelists them (build artifacts / non-code are fine).
122
+ const CODE_LANGS = new Set(['js', 'python', 'ruby', 'brace']);
123
+ const ignoredSource = scanned
124
+ .filter((f) => ignore(path.relative(root, f)) && CODE_LANGS.has(langOf(f)))
125
+ .map((f) => ({ file: path.relative(root, f), lang: path.extname(f).slice(1) }));
126
+ const cells = {};
127
+ const problems = [];
128
+ const perFile = {};
129
+
130
+ for (const file of files) {
131
+ const rel = path.relative(root, file);
132
+ let code = '';
133
+ try { code = fs.readFileSync(file, 'utf8'); } catch (_) {}
134
+ const codeLines = code.split(/\r?\n/);
135
+ const ana = JS_LIKE.test(file) ? analyze(code) : { ok: false, units: [], loose: [] };
136
+ const covered = new Set();
137
+
138
+ let found;
139
+ try { found = extractFile(file); } catch (e) { problems.push({ file: rel, error: e.message }); found = []; }
140
+ for (const cell of found) {
141
+ if (cell.malformed) { problems.push({ id: cell.id, file: rel, error: cell.malformed }); continue; }
142
+ if (cells[cell.id]) { problems.push({ id: cell.id, file: rel, error: `duplicate Cell id (also in ${cells[cell.id].file})` }); continue; }
143
+ let { unitName, unitBody, unitBodyStart, unitFound } = cell;
144
+ let cellModule = moduleNameOf(ana, rel);
145
+ let cellGroup = cell.spec.contains ? 'Modules' : 'Internal';
146
+ let callsOut = [], callsDirect = [], detectedUnit = cell.detectedUnit || null; // extract's regex-detected name (non-JS langs); the JS AST refines it below
147
+ if (ana.ok && !cell.spec.contains) {
148
+ const u = nearestUnitAfter(ana.units, cell.endLine);
149
+ if (u) {
150
+ detectedUnit = u.name; // the ACTUAL function name found in the code
151
+ unitName = cell.spec.unit || u.name;
152
+ unitBody = codeLines.slice(u.startLine - 1, u.endLine).join('\n');
153
+ unitBodyStart = u.startLine - 1;
154
+ unitFound = true;
155
+ covered.add(u.startLine);
156
+ cellGroup = groupOf(u, ana);
157
+ callsOut = u.callsOut || [];
158
+ callsDirect = u.callsDirect || [];
159
+ }
160
+ }
161
+ cells[cell.id] = {
162
+ id: cell.id, file: rel, line: cell.startLine,
163
+ lang: (cell.spec.lang || path.extname(file).slice(1) || 'unknown').split(/[ ·]/)[0],
164
+ langName: normLangName(cell.spec.lang || langNameOf(file)), // canonical language for effect nets / provers
165
+ spec: cell.spec, specBlock: cell.normalized, specHash: sha256(cell.normalized),
166
+ unitName, detectedUnit, unitBody, unitBodyStart, unitFound, module: cellModule, group: cellGroup,
167
+ contains: parseList(cell.spec.contains), feeds: parseList(cell.spec.feeds), callsOut, callsDirect,
168
+ };
169
+ }
170
+ perFile[rel] = { file, ana, covered };
171
+ }
172
+
173
+ // UNTRACKED: AST units with no covering Cell + top-level imperative ("loose") code.
174
+ const coveredNames = new Set(Object.values(cells).map((c) => c.unitName).filter(Boolean));
175
+ const untracked = [];
176
+ for (const rel of Object.keys(perFile)) {
177
+ const { file, ana, covered } = perFile[rel];
178
+ if (ana.ok) {
179
+ for (const u of ana.units) {
180
+ if (covered.has(u.startLine)) continue;
181
+ untracked.push({ name: u.name, file: rel, line: u.startLine, kind: u.kind, lang: path.extname(file).slice(1), module: moduleNameOf(ana, rel), group: groupOf(u, ana) });
182
+ }
183
+ if (ana.loose.length) {
184
+ untracked.push({ name: 'module-level code', file: rel, line: ana.loose[0], kind: 'loose', count: ana.loose.length, lang: path.extname(file).slice(1), module: moduleNameOf(ana, rel), group: 'module-level' });
185
+ }
186
+ } else if (JS_LIKE.test(file)) {
187
+ untrackedRegex(file, rel, coveredNames, untracked);
188
+ } else {
189
+ untrackedLangRegex(file, rel, langOf(file), coveredNames, untracked);
190
+ // Top-level imperative code in a non-JS file (Python/Ruby) → Pink, same as JS.
191
+ // The regex net above only finds un-specced FUNCTIONS; a bare `os.system(...)` at
192
+ // module scope is neither a function nor covered, so without this it slipped past.
193
+ try {
194
+ const loose = looseTopLevelNonJs(fs.readFileSync(file, 'utf8').split(/\r?\n/), langOf(file));
195
+ if (loose.length) untracked.push({ name: 'module-level code', file: rel, line: loose[0], kind: 'loose', count: loose.length, lang: path.extname(file).slice(1), module: baseName(rel), group: 'module-level' });
196
+ } catch (_) {}
197
+ }
198
+ }
199
+
200
+ // Module-level flow (file → file), derived from the call graph: file A feeds
201
+ // into file B if A calls a unit that B defines. Auto-structure, no annotations.
202
+ const defs = {}, callsOf = {};
203
+ for (const rel of Object.keys(perFile)) {
204
+ const { ana } = perFile[rel];
205
+ defs[rel] = new Set(ana.ok ? ana.units.map((u) => String(u.name).split('.').pop()) : []);
206
+ callsOf[rel] = new Set(ana.ok ? ana.calls : []);
207
+ }
208
+ const edgeSet = new Set();
209
+ for (const a of Object.keys(callsOf)) {
210
+ for (const name of callsOf[a]) {
211
+ for (const b of Object.keys(defs)) {
212
+ if (b === a || !defs[b].has(name)) continue;
213
+ if (defs[a].has(name)) continue; // A defines this name itself → not a cross-module call
214
+ const ma = moduleNameOf(perFile[a].ana, a), mb = moduleNameOf(perFile[b].ana, b);
215
+ if (ma !== mb) edgeSet.add(ma + ' >> ' + mb);
216
+ }
217
+ }
218
+ }
219
+ const moduleEdges = [...edgeSet].map((s) => s.split(' >> '));
220
+
221
+ // Reference resolution: a DIRECT call `foo()` should resolve to something defined
222
+ // in the project (any file's bindings/units), an import, or a known global. What's
223
+ // left is a dangling reference — a rename that broke a caller, a typo, a removed fn.
224
+ const declared = new Set(GLOBALS);
225
+ for (const rel of Object.keys(perFile)) {
226
+ const { ana } = perFile[rel];
227
+ if (!ana.ok) continue;
228
+ (ana.bindings || []).forEach((n) => declared.add(n));
229
+ (ana.units || []).forEach((u) => declared.add(shortName(u.name)));
230
+ }
231
+ // NB: resolve against ACTUAL code definitions (bindings/units) only — NOT the spec's
232
+ // claimed `unit:` names, nor claimed exports (publicNames), or a stale/broken
233
+ // `{ createState }` export would mask a caller that references a now-missing function.
234
+ for (const c of Object.values(cells)) {
235
+ c.unresolved = (c.callsDirect || []).filter((n) => !declared.has(n));
236
+ }
237
+
238
+ computeInfluence(cells);
239
+ return { root, cells, problems, untracked, moduleEdges, ignoredSource };
240
+ }
241
+
242
+ const shortName = (n) => String(n || '').split('.').pop();
243
+
244
+ // Cell-level influence, from the unit call graph — no annotations required:
245
+ // directCallers — Cells that call this Cell's unit
246
+ // blast — Cells that TRANSITIVELY depend on it (break if it's wrong)
247
+ // isEntry — part of its module's Public API (external callers expected)
248
+ // bloat — no callers found and not public → possible dead code
249
+ // Name resolution prefers a definer in the caller's own module (most calls are
250
+ // intra-module), falling back to definers elsewhere. Same-short-name collisions
251
+ // across modules are inherently ambiguous — treated as a link, so blast may
252
+ // slightly over-count; it's a signal, not a proof.
253
+ function computeInfluence(cells) {
254
+ const ids = Object.keys(cells);
255
+ const isLeaf = (c) => !(c.contains && c.contains.length);
256
+ const leaves = ids.filter((id) => isLeaf(cells[id]));
257
+ // Null-prototype maps: unit names like "constructor"/"toString" must not collide
258
+ // with inherited Object.prototype members.
259
+ const byName = Object.create(null);
260
+ for (const id of leaves) {
261
+ const n = shortName(cells[id].unitName);
262
+ if (n) (byName[n] = byName[n] || []).push(id);
263
+ }
264
+ const callers = Object.create(null);
265
+ for (const id of ids) callers[id] = new Set();
266
+ for (const id of leaves) {
267
+ const c = cells[id];
268
+ for (const name of c.callsOut || []) {
269
+ const cands = byName[name];
270
+ if (!cands) continue;
271
+ let targets = cands.filter((t) => t !== id && cells[t].module === c.module);
272
+ if (!targets.length) targets = cands.filter((t) => t !== id);
273
+ for (const t of targets) callers[t].add(id);
274
+ }
275
+ }
276
+ for (const id of leaves) {
277
+ const c = cells[id];
278
+ const seen = new Set();
279
+ const stack = [...callers[id]];
280
+ while (stack.length) {
281
+ const x = stack.pop();
282
+ if (seen.has(x)) continue;
283
+ seen.add(x);
284
+ for (const y of callers[x] || []) if (!seen.has(y)) stack.push(y);
285
+ }
286
+ c.directCallers = callers[id].size;
287
+ c.blast = seen.size;
288
+ // Entry points get external callers the static graph can't see: a module's
289
+ // Public API, and DOM event handlers (onclick/onchange/…) invoked by the browser.
290
+ c.isEntry = c.group === 'Public API' || /^on[a-z]+$/.test(shortName(c.unitName));
291
+ // The call graph is built from the JS AST only — for a non-JS Cell "no callers" is
292
+ // blindness, not evidence, so never call it bloat (a misleading dead-code verdict).
293
+ c.bloat = callers[id].size === 0 && !c.isEntry && (!c.lang || isJsLang(c.lang));
294
+ c.callerIds = [...callers[id]];
295
+ }
296
+ }
297
+
298
+ function parseList(v) {
299
+ if (!v) return [];
300
+ return v.replace(/[[\]]/g, '').split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).filter((s) => s !== '—' && s !== '-');
301
+ }
302
+
303
+ module.exports = { buildManifest, parseList, computeInfluence };