vouchington-tooling 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +30 -1
  2. package/dist/agent-blackboard/feedback-audit-source.d.mts +15 -3
  3. package/dist/agent-blackboard/feedback-audit-source.mjs +13 -6
  4. package/dist/agent-blackboard/feedback-codec.d.mts +4 -0
  5. package/dist/agent-blackboard/feedback-codec.mjs +14 -5
  6. package/dist/agent-blackboard/feedback-compose-audit.d.mts +23 -0
  7. package/dist/agent-blackboard/feedback-compose-audit.mjs +45 -0
  8. package/dist/agent-blackboard/feedback-compose.mjs +31 -17
  9. package/dist/agent-blackboard/feedback-journal-audit.d.mts +11 -0
  10. package/dist/agent-blackboard/feedback-journal-audit.mjs +10 -4
  11. package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -1
  12. package/dist/agent-blackboard/feedback-journal-sandbox.mjs +23 -5
  13. package/dist/session-friction/audit-block.d.mts +11 -2
  14. package/dist/session-friction/audit-block.mjs +27 -12
  15. package/dist/session-friction/audit-fit.d.mts +25 -0
  16. package/dist/session-friction/audit-fit.mjs +52 -0
  17. package/dist/session-friction/ci-failures.d.mts +2 -1
  18. package/dist/session-friction/ci-failures.mjs +19 -5
  19. package/dist/session-friction/index.d.mts +1 -1
  20. package/dist/session-friction/index.mjs +1 -1
  21. package/dist/session-friction/report.d.mts +15 -1
  22. package/dist/session-friction/report.mjs +25 -12
  23. package/dist/session-friction/sandbox.d.mts +2 -1
  24. package/dist/session-friction/sandbox.mjs +28 -7
  25. package/dist/session-friction/text.d.mts +14 -1
  26. package/dist/session-friction/text.mjs +34 -9
  27. package/dist/session-friction/types.d.mts +7 -0
  28. package/dist/shared-context/source-graph-owner.d.mts +4 -0
  29. package/dist/shared-context/source-graph-owner.mjs +67 -0
  30. package/dist/shared-context/source-graph-resolve.d.mts +8 -0
  31. package/dist/shared-context/source-graph-resolve.mjs +60 -0
  32. package/dist/shared-context/source-graph.d.mts +24 -0
  33. package/dist/shared-context/source-graph.mjs +86 -0
  34. package/dist/source-module-graph/index.d.mts +2 -0
  35. package/dist/source-module-graph/index.mjs +1 -0
  36. package/package.json +11 -1
  37. package/scripts/allocate-browser-safe-ports.py +3 -0
@@ -13,8 +13,22 @@ const FIELD_PREFIXES = [
13
13
  ' - Root diagnostic: ',
14
14
  ' - Disposition: ',
15
15
  ];
16
- function matchBlock(markdown) {
17
- return matchAuditBlock(markdown, [GROUP_HEADER, EVIDENCE, ROOT_DIAGNOSTIC, DISPOSITION], FIELD_PREFIXES);
16
+ const CI_SECTION_MAX_BYTES = 4_000;
17
+ // Field order: header, Evidence, Root diagnostic, Disposition. Cut Root diagnostic first and
18
+ // Evidence last, so the run URL and commit SHA survive longest.
19
+ const SHRINK_ORDER = [2, 3, 0, 1];
20
+ const SESSION_ID_MAX_BYTES = 120;
21
+ function matchBlock(markdown, render = {}) {
22
+ return matchAuditBlock(markdown, [GROUP_HEADER, EVIDENCE, ROOT_DIAGNOSTIC, DISPOSITION], FIELD_PREFIXES, { ...render, shrinkOrder: SHRINK_ORDER });
23
+ }
24
+ /** A conforming group that reports dropped groups, so omission is never silent. */
25
+ function omissionGroup(count) {
26
+ return [
27
+ `- \`one-off\` — \`GitHub Actions\` — ${count} further failure groups omitted to fit the size limit; see journal`,
28
+ ' - Evidence: omitted groups remain in the session journal',
29
+ ' - Root diagnostic: omitted to fit the retrospective size limit',
30
+ ' - Disposition: see journal for the omitted failure groups',
31
+ ].join('\n');
18
32
  }
19
33
  export function isConformingCiFailureBlock(markdown) {
20
34
  if (markdown.length > CI_FAILURE_BLOCK_MAX_BYTES ||
@@ -22,8 +36,8 @@ export function isConformingCiFailureBlock(markdown) {
22
36
  return false;
23
37
  return matchBlock(markdown) !== null;
24
38
  }
25
- export function getConformingGroups(entries) {
26
- return conformingBlocks(entries, matchBlock);
39
+ export function getConformingGroups(entries, redact, maxBytes = CI_SECTION_MAX_BYTES) {
40
+ return conformingBlocks(entries, matchBlock, Math.min(maxBytes, CI_SECTION_MAX_BYTES), redact, omissionGroup);
27
41
  }
28
42
  export function incompleteCiSection(markdownBlocks) {
29
43
  return [
@@ -35,7 +49,7 @@ export function buildCiFailuresSection(sessionId, journal, frictionStatus) {
35
49
  if (journal.status === 'unreachable')
36
50
  return `${CI_FAILURES_HEADER}\nStatus: unavailable (blackboard unreachable)`;
37
51
  if (journal.markdownBlocks.length === 0 && frictionStatus === 'absent')
38
- return `${CI_FAILURES_HEADER}\nStatus: unavailable (no friction log for session ${markdownAuditText(sessionId)})`;
52
+ return `${CI_FAILURES_HEADER}\nStatus: unavailable (no friction log for session ${markdownAuditText(sessionId, undefined, SESSION_ID_MAX_BYTES)})`;
39
53
  if (journal.markdownBlocks.length === 0)
40
54
  return `${CI_FAILURES_HEADER}\nStatus: none observed`;
41
55
  return `${CI_FAILURES_HEADER}\nStatus: failures observed\n\n${journal.markdownBlocks.join('\n\n')}`;
@@ -1,6 +1,6 @@
1
1
  export { classifyFrictionObservation } from './classify.mts';
2
2
  export { normalizeCommandPrefix } from './normalize.mts';
3
3
  export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from './log.mts';
4
- export { buildSessionFrictionReport } from './report.mts';
4
+ export { buildSessionFrictionReport, loadFrictionReportInputs, renderFrictionReport, type FrictionReportInputs, } from './report.mts';
5
5
  export { isConformingCiFailureBlock } from './ci-failures.mts';
6
6
  export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoadResult, JournalLoader, PermissionRequestObservation, SessionFrictionReport, SessionFrictionCoverage, SessionFrictionReportOptions, ToolResultObservation, } from './types.mts';
@@ -1,5 +1,5 @@
1
1
  export { classifyFrictionObservation } from "./classify.mjs";
2
2
  export { normalizeCommandPrefix } from "./normalize.mjs";
3
3
  export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from "./log.mjs";
4
- export { buildSessionFrictionReport } from "./report.mjs";
4
+ export { buildSessionFrictionReport, loadFrictionReportInputs, renderFrictionReport, } from "./report.mjs";
5
5
  export { isConformingCiFailureBlock } from "./ci-failures.mjs";
@@ -1,2 +1,16 @@
1
- import type { SessionFrictionReport, SessionFrictionReportOptions } from './types.mts';
1
+ import { type JournalScan } from './journal.mts';
2
+ import type { FrictionLogReadResult, SessionFrictionReport, SessionFrictionReportOptions } from './types.mts';
3
+ type FrictionRead = {
4
+ result: FrictionLogReadResult;
5
+ } | {
6
+ error: unknown;
7
+ };
8
+ /** Everything a report reads from outside, loaded once so rendering can be repeated for free. */
9
+ export type FrictionReportInputs = {
10
+ scanned: JournalScan;
11
+ read: FrictionRead;
12
+ };
13
+ export declare function loadFrictionReportInputs(sessionId: string, options: SessionFrictionReportOptions): Promise<FrictionReportInputs>;
14
+ export declare function renderFrictionReport(sessionId: string, options: SessionFrictionReportOptions, { scanned, read }: FrictionReportInputs): SessionFrictionReport;
2
15
  export declare function buildSessionFrictionReport(sessionId: string, options: SessionFrictionReportOptions): Promise<SessionFrictionReport>;
16
+ export {};
@@ -3,17 +3,17 @@ import { buildCiFailuresSection, getConformingGroups, incompleteCiSection } from
3
3
  import { errorMessage, scanJournal } from "./journal.mjs";
4
4
  import { buildSandboxSection } from "./sandbox.mjs";
5
5
  import { validateSessionId } from "./session-id.mjs";
6
- function sandboxMarkdown(friction) {
6
+ function sandboxMarkdown(friction, options) {
7
7
  if (friction.status === 'events')
8
- return `${friction.truncated ? `## Capture Coverage\nStatus: partial\nDropped records: ${friction.droppedCount}\n\n` : ''}${buildSandboxSection(friction.events)}`;
8
+ return `${friction.truncated ? `## Capture Coverage\nStatus: partial\nDropped records: ${friction.droppedCount}\n\n` : ''}${buildSandboxSection(friction.events, options.redact, options.budgets?.sandboxBytes)}`;
9
9
  if (friction.status === 'empty' && friction.truncated)
10
10
  return `## Sandbox & Permission Audit\nStatus: unavailable (partial capture; dropped ${friction.droppedCount} records)`;
11
11
  const status = friction.status === 'empty' ? 'none observed' : 'unavailable (no friction log)';
12
12
  return `## Sandbox & Permission Audit\nStatus: ${status}`;
13
13
  }
14
- function reportFromLog(sessionId, logOptions, journal) {
15
- try {
16
- const friction = readFrictionLog(sessionId, logOptions);
14
+ function reportFromLog(sessionId, logOptions, journal, read) {
15
+ if ('result' in read) {
16
+ const friction = read.result;
17
17
  const coverage = {
18
18
  journalStatus: journal.status === 'unreachable'
19
19
  ? 'unavailable'
@@ -27,12 +27,14 @@ function reportFromLog(sessionId, logOptions, journal) {
27
27
  if (journal.status === 'ok' && journal.truncated)
28
28
  return {
29
29
  coverage,
30
- markdown: incompleteCiSection(journal.markdownBlocks) + '\n\n' + sandboxMarkdown(friction),
30
+ markdown: incompleteCiSection(journal.markdownBlocks) +
31
+ '\n\n' +
32
+ sandboxMarkdown(friction, logOptions),
31
33
  };
32
34
  const markdown = buildCiFailuresSection(sessionId, journal, friction.status);
33
- return { coverage, markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
35
+ return { coverage, markdown: `${markdown}\n\n${sandboxMarkdown(friction, logOptions)}` };
34
36
  }
35
- catch (error) {
37
+ else {
36
38
  const ciMarkdown = journal.status === 'ok' && journal.truncated
37
39
  ? incompleteCiSection(journal.markdownBlocks)
38
40
  : journal.status === 'ok' && journal.markdownBlocks.length === 0
@@ -49,25 +51,33 @@ function reportFromLog(sessionId, logOptions, journal) {
49
51
  truncated: true,
50
52
  },
51
53
  markdown: `${ciMarkdown}\n\n## Sandbox & Permission Audit\nStatus: unavailable (friction log unreadable)`,
52
- diagnostic: errorMessage(error),
54
+ diagnostic: errorMessage(read.error),
53
55
  };
54
56
  }
55
57
  }
56
- export async function buildSessionFrictionReport(sessionId, options) {
58
+ export async function loadFrictionReportInputs(sessionId, options) {
57
59
  validateSessionId(sessionId);
58
60
  requireDirectory(options.directory);
59
61
  eventLimit(options.maxEvents);
60
62
  const scanned = await scanJournal(sessionId, options.journalLoader);
63
+ try {
64
+ return { scanned, read: { result: readFrictionLog(sessionId, options) } };
65
+ }
66
+ catch (error) {
67
+ return { scanned, read: { error } };
68
+ }
69
+ }
70
+ export function renderFrictionReport(sessionId, options, { scanned, read }) {
61
71
  const journal = scanned.status === 'unreachable'
62
72
  ? scanned
63
73
  : scanned.status === 'not-found'
64
74
  ? { status: 'ok', markdownBlocks: [], truncated: false }
65
75
  : {
66
76
  status: 'ok',
67
- markdownBlocks: getConformingGroups(scanned.entries),
77
+ markdownBlocks: getConformingGroups(scanned.entries, options.redact, options.budgets?.ciBytes),
68
78
  truncated: scanned.truncated,
69
79
  };
70
- const report = reportFromLog(sessionId, options, journal);
80
+ const report = reportFromLog(sessionId, options, journal, read);
71
81
  if (journal.status === 'unreachable') {
72
82
  const diagnostic = report.diagnostic
73
83
  ? `${journal.diagnostic}; ${report.diagnostic}`
@@ -76,3 +86,6 @@ export async function buildSessionFrictionReport(sessionId, options) {
76
86
  }
77
87
  return report;
78
88
  }
89
+ export async function buildSessionFrictionReport(sessionId, options) {
90
+ return renderFrictionReport(sessionId, options, await loadFrictionReportInputs(sessionId, options));
91
+ }
@@ -1,2 +1,3 @@
1
1
  import type { FrictionEvent } from './types.mts';
2
- export declare function buildSandboxSection(events: FrictionEvent[]): string;
2
+ import { type AuditRedactor } from './text.mts';
3
+ export declare function buildSandboxSection(events: FrictionEvent[], redact?: AuditRedactor, maxBytes?: number): string;
@@ -1,22 +1,43 @@
1
- import { markdownAuditText } from "./text.mjs";
1
+ import { byteLength, fitFields, selectWithin } from "./audit-fit.mjs";
2
+ import { markdownAuditUnits } from "./text.mjs";
2
3
  const SANDBOX_SECTION_HEADER = '## Sandbox & Permission Audit';
4
+ const SANDBOX_SECTION_MAX_BYTES = 3_000;
5
+ // Room for the `- kind (n)` group lines, which are rendered outside the per-event budget.
6
+ const GROUP_LINES_BYTES = 120;
7
+ // Below this an event line would keep little beyond its timestamp, so it is omitted and counted.
8
+ const MIN_EVENT_SHARE_BYTES = 120;
9
+ // Event line fields: command prefix, detail, timestamp. Cut detail first, the timestamp never.
10
+ const SHRINK_ORDER = [1, 0];
3
11
  const KIND_ORDER = [
4
12
  'sandbox-escalation',
5
13
  'sandbox-failure',
6
14
  'ambiguous-failure',
7
15
  ];
8
- export function buildSandboxSection(events) {
16
+ /** Reports dropped events as a group in the same shape as the rest, so omission is never silent. */
17
+ function omissionGroup(count) {
18
+ return `- omitted (${count})\n - see journal — ${count} events omitted to fit the size limit — n/a`;
19
+ }
20
+ export function buildSandboxSection(events, redact, maxBytes = SANDBOX_SECTION_MAX_BYTES) {
9
21
  const observations = events.flatMap((event) => event.failure
10
22
  ? [event, { ...event, kind: event.failure.kind, detail: event.failure.detail }]
11
23
  : [event]);
24
+ const render = (event, budgetBytes) => {
25
+ const outcome = event.kind === 'sandbox-escalation' ? ` — outcome: ${event.outcome ?? 'unknown'}` : '';
26
+ const [prefix, detail, timestamp] = fitFields([event.commandPrefix, event.detail, event.timestamp].map((field) => markdownAuditUnits(field, redact)), byteLength(` - — — ${outcome}`), budgetBytes, SHRINK_ORDER);
27
+ return ` - ${prefix} — ${detail} — ${timestamp}${outcome}`;
28
+ };
29
+ const total = Math.max(0, Math.min(maxBytes, SANDBOX_SECTION_MAX_BYTES) - GROUP_LINES_BYTES);
30
+ const { rendered, omitted } = selectWithin(observations, total, byteLength(omissionGroup(observations.length)) + 1, 1, MIN_EVENT_SHARE_BYTES, render);
31
+ const kept = observations
32
+ .slice(0, rendered.length)
33
+ .map((event, i) => ({ event, line: rendered[i] }));
12
34
  const groups = KIND_ORDER.map((kind) => {
13
- const selected = observations.filter((event) => event.kind === kind);
35
+ const selected = kept.filter(({ event }) => event.kind === kind);
14
36
  return selected.length
15
- ? [
16
- `- ${kind} (${selected.length})`,
17
- ...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}${event.kind === 'sandbox-escalation' ? ` — outcome: ${event.outcome ?? 'unknown'}` : ''}`),
18
- ].join('\n')
37
+ ? [`- ${kind} (${selected.length})`, ...selected.map(({ line }) => line)].join('\n')
19
38
  : undefined;
20
39
  }).filter((group) => group !== undefined);
40
+ if (omitted)
41
+ groups.push(omissionGroup(omitted));
21
42
  return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${observations.length}\n\n${groups.join('\n')}`;
22
43
  }
@@ -2,4 +2,17 @@ export declare function normalizeAuditText(value: string): string;
2
2
  export declare function boundedText(value: string, maximum: number): string;
3
3
  export declare function isWellFormedUnicode(value: string): boolean;
4
4
  export declare function isSafeAuditText(value: unknown): value is string;
5
- export declare function markdownAuditText(value: string): string;
5
+ export type AuditRedactor = (value: string) => string;
6
+ /**
7
+ * Splits a field into atomic Markdown-escaped units (one code point, plus its backslash when
8
+ * escaped), so a later cut can never land inside an escape sequence. Redaction runs on the raw
9
+ * text first: escaping inserts backslashes that would otherwise break secret and token matching.
10
+ * normalizeAuditText trims and escaping adds no whitespace, so the result has none at its edges.
11
+ */
12
+ export declare function markdownAuditUnits(value: string, redact?: AuditRedactor): string[];
13
+ /**
14
+ * Joins escaped units whole, or, when over `maxBytes`, keeps only complete whitespace-separated
15
+ * tokens (so URLs and SHAs are never split) and marks the cut. Never ends in whitespace.
16
+ */
17
+ export declare function fitUnits(units: string[], maxBytes: number, atTokens?: boolean): string;
18
+ export declare function markdownAuditText(value: string, redact?: AuditRedactor, maxBytes?: number): string;
@@ -1,6 +1,5 @@
1
1
  const CONTROL_CHARACTERS = /[\p{Cc}\p{Cf}]+/gu;
2
2
  const MARKDOWN_CHARACTER = /\\|`|\*|_|~|\[|\]|<|>|#|-|\+|!|\||&/;
3
- const MARKDOWN_AUDIT_MAX_LENGTH = 120;
4
3
  export function normalizeAuditText(value) {
5
4
  return value.replace(CONTROL_CHARACTERS, ' ').trim();
6
5
  }
@@ -32,14 +31,40 @@ export function isSafeAuditText(value) {
32
31
  isWellFormedUnicode(value) &&
33
32
  value === normalizeAuditText(value));
34
33
  }
35
- export function markdownAuditText(value) {
36
- // Escaping is atomic, so the result may be shorter than the maximum.
37
- let result = '';
38
- for (const character of normalizeAuditText(value)) {
39
- const escaped = MARKDOWN_CHARACTER.test(character) ? `\\${character}` : character;
40
- if (result.length + escaped.length > MARKDOWN_AUDIT_MAX_LENGTH)
34
+ /**
35
+ * Splits a field into atomic Markdown-escaped units (one code point, plus its backslash when
36
+ * escaped), so a later cut can never land inside an escape sequence. Redaction runs on the raw
37
+ * text first: escaping inserts backslashes that would otherwise break secret and token matching.
38
+ * normalizeAuditText trims and escaping adds no whitespace, so the result has none at its edges.
39
+ */
40
+ export function markdownAuditUnits(value, redact) {
41
+ const raw = normalizeAuditText(value);
42
+ return Array.from(redact ? normalizeAuditText(redact(raw)) : raw).map((character) => MARKDOWN_CHARACTER.test(character) ? `\\${character}` : character);
43
+ }
44
+ const CUT_MARKER = '…';
45
+ const WHITESPACE = /^\s$/u;
46
+ /**
47
+ * Joins escaped units whole, or, when over `maxBytes`, keeps only complete whitespace-separated
48
+ * tokens (so URLs and SHAs are never split) and marks the cut. Never ends in whitespace.
49
+ */
50
+ export function fitUnits(units, maxBytes, atTokens = true) {
51
+ const whole = units.join('');
52
+ if (Buffer.byteLength(whole) <= maxBytes)
53
+ return whole;
54
+ const room = maxBytes - Buffer.byteLength(` ${CUT_MARKER}`);
55
+ let used = 0;
56
+ let boundary = 0;
57
+ for (const [index, unit] of units.entries()) {
58
+ used += Buffer.byteLength(unit);
59
+ if (used > room)
41
60
  break;
42
- result += escaped;
61
+ if (!atTokens || WHITESPACE.test(unit))
62
+ boundary = atTokens ? index : index + 1;
43
63
  }
44
- return result;
64
+ const kept = units.slice(0, boundary).join('').trimEnd();
65
+ return kept ? `${kept}${atTokens ? ' ' : ''}${CUT_MARKER}` : CUT_MARKER;
66
+ }
67
+ export function markdownAuditText(value, redact, maxBytes = Infinity) {
68
+ // Used for identifiers, which have no token boundaries to cut at.
69
+ return fitUnits(markdownAuditUnits(value, redact), maxBytes, false);
45
70
  }
@@ -69,5 +69,12 @@ export type SessionFrictionReport = {
69
69
  };
70
70
  export type SessionFrictionReportOptions = FrictionLogOptions & {
71
71
  journalLoader: JournalLoader;
72
+ /** Redacts raw field text before Markdown escaping, which would otherwise break matching. */
73
+ redact?: (value: string) => string;
74
+ /** Byte allowances for the CI and sandbox sections, as maximums over the built-in budgets. */
75
+ budgets?: {
76
+ ciBytes: number;
77
+ sandboxBytes: number;
78
+ };
72
79
  };
73
80
  export {};
@@ -0,0 +1,4 @@
1
+ import type { TypeScriptModuleFacts } from 'no-mistakes';
2
+ import { type SourceGraphPaths } from './source-graph-resolve.mts';
3
+ import type { SourceExportOwner } from './source-graph.mts';
4
+ export declare function createExportOwnerResolver(paths: SourceGraphPaths, factsFor: (file: string) => TypeScriptModuleFacts, maxFiles: number): (file: string, exportName: string) => SourceExportOwner | null;
@@ -0,0 +1,67 @@
1
+ import { relativeFile, resolveSourcePath } from "./source-graph-resolve.mjs";
2
+ export function createExportOwnerResolver(paths, factsFor, maxFiles) {
3
+ return (file, exportName) => {
4
+ const start = relativeFile(paths.root, file);
5
+ if (!start || !paths.files.has(start))
6
+ return null;
7
+ const queue = [{ file: start, exportName }];
8
+ const seen = new Set();
9
+ const owners = new Map();
10
+ let unresolved = false;
11
+ for (let index = 0; index < queue.length; index++) {
12
+ const current = queue[index];
13
+ const key = `${current.file}\0${current.exportName}`;
14
+ if (seen.has(key))
15
+ continue;
16
+ seen.add(key);
17
+ if (seen.size > maxFiles)
18
+ throw new Error('Source graph file limit exceeded');
19
+ const facts = factsFor(current.file);
20
+ const direct = facts.exports.filter((item) => !item.typeOnly && item.exported === current.exportName);
21
+ const matches = direct.length > 0
22
+ ? direct
23
+ : current.exportName === 'default'
24
+ ? [] // ESM export-star never reexports default.
25
+ : facts.exports.filter((item) => !item.typeOnly && item.local === '*' && item.exported === '*');
26
+ for (const item of matches) {
27
+ if (item.specifier) {
28
+ const target = resolveSourcePath(paths, current.file, item.specifier);
29
+ if (!target) {
30
+ unresolved = true;
31
+ }
32
+ else if (item.local === '*' && item.exported !== '*') {
33
+ factsFor(target);
34
+ owners.set(`${target}\0*`, { file: target, exportName: '*' });
35
+ }
36
+ else {
37
+ queue.push({
38
+ file: target,
39
+ exportName: item.local === '*' ? current.exportName : item.local,
40
+ });
41
+ }
42
+ continue;
43
+ }
44
+ const imported = facts.imports.flatMap((entry) => entry.bindings
45
+ .filter((binding) => !binding.typeOnly && binding.local === item.local)
46
+ .map((binding) => ({ entry, binding })))[0];
47
+ if (imported) {
48
+ const target = resolveSourcePath(paths, current.file, imported.entry.specifier);
49
+ if (!target) {
50
+ unresolved = true;
51
+ }
52
+ else if (imported.binding.kind === 'namespace') {
53
+ factsFor(target);
54
+ owners.set(`${target}\0*`, { file: target, exportName: '*' });
55
+ }
56
+ else {
57
+ queue.push({ file: target, exportName: imported.binding.imported });
58
+ }
59
+ continue;
60
+ }
61
+ const owner = { file: current.file, exportName: item.exported };
62
+ owners.set(`${owner.file}\0${owner.exportName}`, owner);
63
+ }
64
+ }
65
+ return !unresolved && owners.size === 1 ? [...owners.values()][0] : null;
66
+ };
67
+ }
@@ -0,0 +1,8 @@
1
+ export type SourceGraphPaths = {
2
+ root: string;
3
+ extensions: readonly string[];
4
+ aliases: Readonly<Record<string, string>>;
5
+ files: ReadonlySet<string>;
6
+ };
7
+ export declare function relativeFile(root: string, file: string): string | null;
8
+ export declare function resolveSourcePath(paths: SourceGraphPaths, fromFile: string, specifier: string): string | null;
@@ -0,0 +1,60 @@
1
+ import { isAbsolute, posix, relative, win32 } from 'node:path';
2
+ export function relativeFile(root, file) {
3
+ const value = isAbsolute(file) ? relative(root, file) : file;
4
+ if (win32.isAbsolute(value) || /^[A-Za-z]:/u.test(value))
5
+ return null;
6
+ const normalized = posix.normalize(value.replaceAll('\\', '/'));
7
+ return normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized)
8
+ ? null
9
+ : normalized;
10
+ }
11
+ export function resolveSourcePath(paths, fromFile, specifier) {
12
+ const normalizedFrom = relativeFile(paths.root, fromFile);
13
+ if (!normalizedFrom || !paths.files.has(normalizedFrom))
14
+ return null;
15
+ let target = null;
16
+ if (specifier.startsWith('.')) {
17
+ target = posix.join(posix.dirname(normalizedFrom), specifier);
18
+ }
19
+ else {
20
+ const alias = Object.keys(paths.aliases)
21
+ .filter((prefix) => specifier.startsWith(prefix))
22
+ .sort((a, b) => b.length - a.length)[0];
23
+ if (alias)
24
+ target = posix.join(paths.aliases[alias], specifier.slice(alias.length));
25
+ }
26
+ if (!target)
27
+ return null;
28
+ const base = relativeFile(paths.root, target);
29
+ if (!base)
30
+ return null;
31
+ const runtimeExtension = posix.extname(base);
32
+ const mappedExtensions = {
33
+ '.js': ['.ts', '.tsx'],
34
+ '.jsx': ['.tsx'],
35
+ '.mjs': ['.mts'],
36
+ '.cjs': ['.cts'],
37
+ };
38
+ const mapped = mappedExtensions[runtimeExtension];
39
+ if (mapped) {
40
+ const stem = base.slice(0, -runtimeExtension.length);
41
+ for (const suffix of paths.extensions) {
42
+ if (!mapped.includes(suffix))
43
+ continue;
44
+ const candidate = `${stem}${suffix}`;
45
+ if (paths.files.has(candidate))
46
+ return candidate;
47
+ }
48
+ }
49
+ for (const suffix of paths.extensions) {
50
+ const candidate = `${base}${suffix}`;
51
+ if (paths.files.has(candidate))
52
+ return candidate;
53
+ }
54
+ for (const suffix of paths.extensions) {
55
+ const candidate = posix.join(base, `index${suffix}`);
56
+ if (paths.files.has(candidate))
57
+ return candidate;
58
+ }
59
+ return paths.files.has(base) ? base : null;
60
+ }
@@ -0,0 +1,24 @@
1
+ import type { TypeScriptModuleImport, TypeScriptModulesReport } from 'no-mistakes';
2
+ export type SourceModuleGraphOptions = {
3
+ root: string;
4
+ extensions: readonly string[];
5
+ aliases?: Readonly<Record<string, string>>;
6
+ files: readonly string[];
7
+ facts: TypeScriptModulesReport;
8
+ /** Caps the supplied file inventory and every traversal. */
9
+ maxFiles?: number;
10
+ };
11
+ export type SourceExportOwner = {
12
+ file: string;
13
+ exportName: string;
14
+ };
15
+ export type SourceModuleGraph = {
16
+ resolveSource(fromFile: string, specifier: string): string | null;
17
+ runtimeImports(file: string): TypeScriptModuleImport[];
18
+ reachableFrom(entrypoints: readonly string[], options?: {
19
+ runtimeOnly?: boolean;
20
+ }): string[];
21
+ resolveExportOwner(file: string, exportName: string): SourceExportOwner | null;
22
+ };
23
+ /** Resolve only caller-supplied files; syntax and binding facts come from no-mistakes. */
24
+ export declare function createSourceModuleGraph(options: SourceModuleGraphOptions): SourceModuleGraph;
@@ -0,0 +1,86 @@
1
+ import { resolve } from 'node:path';
2
+ import { createExportOwnerResolver } from "./source-graph-owner.mjs";
3
+ import { relativeFile, resolveSourcePath } from "./source-graph-resolve.mjs";
4
+ /** Resolve only caller-supplied files; syntax and binding facts come from no-mistakes. */
5
+ export function createSourceModuleGraph(options) {
6
+ const root = resolve(options.root);
7
+ const maxFiles = options.maxFiles ?? 10_000;
8
+ if (!Number.isSafeInteger(maxFiles) || maxFiles < 1 || options.files.length > maxFiles) {
9
+ throw new Error('Source graph file limit exceeded');
10
+ }
11
+ const fileSet = new Set();
12
+ for (const file of options.files) {
13
+ const normalized = relativeFile(root, file);
14
+ if (!normalized)
15
+ throw new Error(`Source graph file escapes root: ${file}`);
16
+ fileSet.add(normalized);
17
+ }
18
+ const paths = {
19
+ root,
20
+ extensions: options.extensions,
21
+ aliases: options.aliases ?? {},
22
+ files: fileSet,
23
+ };
24
+ const modules = new Map();
25
+ for (const module of options.facts.modules) {
26
+ const file = relativeFile(root, module.fileName);
27
+ if (!file || !fileSet.has(file) || modules.has(file)) {
28
+ throw new Error(`Unexpected source module facts: ${module.fileName}`);
29
+ }
30
+ modules.set(file, module);
31
+ }
32
+ function factsFor(file) {
33
+ const normalized = relativeFile(root, file);
34
+ const facts = normalized && fileSet.has(normalized) ? modules.get(normalized) : undefined;
35
+ if (!facts || !facts.complete)
36
+ throw new Error(`Incomplete source module facts: ${file}`);
37
+ return facts;
38
+ }
39
+ function runtimeImports(file) {
40
+ const facts = factsFor(file);
41
+ return facts.imports.flatMap((entry) => {
42
+ if (entry.typeOnly)
43
+ return [];
44
+ if (entry.bindings.length === 0)
45
+ return [entry]; // Side-effect import.
46
+ const bindings = entry.bindings.filter((imported) => {
47
+ if (imported.typeOnly)
48
+ return false;
49
+ const binding = facts.bindings.find((item) => item.id === imported.bindingId);
50
+ if (!binding)
51
+ throw new Error(`Missing import binding facts: ${file}`);
52
+ return binding.references.some((reference) => reference.runtime && !reference.typeOnly);
53
+ });
54
+ return bindings.length > 0 ? [{ ...entry, bindings }] : [];
55
+ });
56
+ }
57
+ function reachableFrom(entrypoints, traversal = {}) {
58
+ const queue = entrypoints.map((file) => relativeFile(root, file));
59
+ const seen = new Set();
60
+ for (let index = 0; index < queue.length; index++) {
61
+ const file = queue[index];
62
+ if (!file || !fileSet.has(file) || seen.has(file))
63
+ continue;
64
+ seen.add(file);
65
+ const facts = factsFor(file);
66
+ const imports = traversal.runtimeOnly ? runtimeImports(file) : facts.imports;
67
+ const specifiers = [
68
+ ...imports.map((item) => item.specifier),
69
+ ...facts.exports.flatMap((item) => item.specifier && (!traversal.runtimeOnly || !item.typeOnly) ? [item.specifier] : []),
70
+ ...facts.loads.map((item) => item.specifier),
71
+ ];
72
+ for (const specifier of specifiers) {
73
+ const resolved = resolveSourcePath(paths, file, specifier);
74
+ if (resolved && !seen.has(resolved))
75
+ queue.push(resolved);
76
+ }
77
+ }
78
+ return [...seen].sort();
79
+ }
80
+ return {
81
+ resolveSource: (fromFile, specifier) => resolveSourcePath(paths, fromFile, specifier),
82
+ runtimeImports,
83
+ reachableFrom,
84
+ resolveExportOwner: createExportOwnerResolver(paths, factsFor, maxFiles),
85
+ };
86
+ }
@@ -0,0 +1,2 @@
1
+ export { createSourceModuleGraph } from '../shared-context/source-graph.mts';
2
+ export type { SourceExportOwner, SourceModuleGraph, SourceModuleGraphOptions, } from '../shared-context/source-graph.mts';
@@ -0,0 +1 @@
1
+ export { createSourceModuleGraph } from "../shared-context/source-graph.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -136,6 +136,11 @@
136
136
  "import": "./dist/shared-context/index.mjs",
137
137
  "default": "./dist/shared-context/index.mjs"
138
138
  },
139
+ "./source-module-graph": {
140
+ "types": "./dist/source-module-graph/index.d.mts",
141
+ "import": "./dist/source-module-graph/index.mjs",
142
+ "default": "./dist/source-module-graph/index.mjs"
143
+ },
139
144
  "./gha-selected-files": {
140
145
  "types": "./dist/gha-selected-files/index.d.mts",
141
146
  "import": "./dist/gha-selected-files/index.mjs",
@@ -376,12 +381,14 @@
376
381
  "@types/picomatch": "^4.0.3",
377
382
  "@types/spdx-expression-parse": "4.0.0",
378
383
  "agent-blackboard": "^0.6.0",
384
+ "no-mistakes": "0.78.0",
379
385
  "smol-toml": "1.9.0",
380
386
  "typescript5": "npm:typescript@5.9.3"
381
387
  },
382
388
  "peerDependencies": {
383
389
  "@modelcontextprotocol/sdk": "^1.31.0",
384
390
  "agent-blackboard": "^0.6.0",
391
+ "no-mistakes": "^0.78.0",
385
392
  "typescript": ">=5",
386
393
  "vitest": ">=5"
387
394
  },
@@ -392,6 +399,9 @@
392
399
  "@modelcontextprotocol/sdk": {
393
400
  "optional": true
394
401
  },
402
+ "no-mistakes": {
403
+ "optional": true
404
+ },
395
405
  "typescript": {
396
406
  "optional": true
397
407
  },
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env python3
2
+ # Must stay stdlib-only and self-contained (reads only the sibling fetch-forbidden-ports.json
3
+ # and runner-port-policy.json): CI runs it from the extracted published tarball with no install.
4
+ # Enforced by allocate-browser-safe-ports-no-install.test.mts.
2
5
  from __future__ import annotations
3
6
 
4
7
  import argparse