fix(ui): count the jobs, triggers and actions of an open workflow file

The small model still invented structure in Explore: "Es gibt 3 Jobs"
for a workflow with one job, and the trigger type "opened" became a
branch name. The jobs, triggers, steps and actions of a whole GitHub
Actions workflow are now counted from its keys and given to the model
as facts, and the automatic explanation lists them above the model text
with a note that they were read from the file.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-03 20:51:41 +02:00
parent 76ad98fdba
commit 55ebda40e6
11 changed files with 264 additions and 11 deletions
@@ -1425,6 +1425,21 @@ describe('grounded automatic explanations (K14, K7)', () => {
expect(card().textContent).toContain('Listed from the indexed graph; not generated by the model.');
});
it('lists the jobs, triggers and actions of a workflow file above the model text (K12)', async () => {
vi.useFakeTimers(); const { props, runtime } = fixture();
runtime.chat.mockResolvedValueOnce('This workflow greets new contributors on their first pull request.');
const text = 'name: New contributor message\n\non:\n pull_request_target:\n types: [opened]\n\njobs:\n build:\n name: Hello new contributor\n runs-on: ubuntu-latest\n steps:\n - uses: actions/first-interaction@v1\n';
await render({ ...props, proactive: true, selectionScope: 'django-demo:explore', readerContext: reader(text, 'file', '.github/workflows/new_contributor_pr.yml') });
await click('Download & load'); await settle();
const prompt = runtime.chat.mock.calls[0][0].map(message => message.content).join('\n');
expect(prompt).toContain('Facts read from the file (counted, not guessed):');
expect(prompt).toContain('1 job: `build`');
expect(card().textContent).toContain('1 job: build ("Hello new contributor", runs on ubuntu-latest, 1 step).');
expect(card().textContent).toContain('Actions used: actions/first-interaction@v1.');
expect(card().textContent).toContain('This workflow greets new contributors on their first pull request.');
expect(card().textContent).toContain('Facts read from the file; the text after them is generated by the model.');
});
it('explains an Architecture area from readable facts without reading source', async () => {
vi.useFakeTimers(); const { props, runtime } = fixture();
const readSource = vi.fn(async () => snippet);
+10 -6
View File
@@ -16,6 +16,7 @@ import ChatMarkdown from './ChatMarkdown';
import AgentSettingsDialog from './AgentSettingsDialog';
import { useChatHistory } from './use-chat-history';
import { chatTopic, followedTopic, missingContextAnswer, topicHistory } from './chat-context';
import { readerFacts } from './workflow-facts';
import './browser-chat.css';
export type { BrowserChatAttachment, BrowserChatContext, BrowserChatReaderContext, BrowserChatSource } from './chat-model';
@@ -60,9 +61,11 @@ const sizeLabel = (bytes: number): string => bytes >= 1_000_000_000 ? `${(bytes
/** Symbol sources read for explanations and questions, newest last. */
const SOURCE_CACHE_SIZE = 32;
/** The listed facts first, then the model's sentence if it stood the check, then who wrote what (K7). */
function groundedExplanation(summary: readonly string[], sentence: string | undefined, dropped: boolean): string {
const note = sentence ? browserChatText.factsAndSentence : dropped ? browserChatText.sentenceDropped : browserChatText.factsOnly;
/** The listed facts first, then the model's sentence if it stood the check, then who wrote what (K7).
* The facts come from the indexed graph, or for an open workflow from the file itself (K12). */
function groundedExplanation(summary: readonly string[], sentence: string | undefined, dropped: boolean, from: 'graph' | 'file' = 'graph'): string {
const note = from === 'file' ? sentence ? browserChatText.fileFactsAndSentence : dropped ? browserChatText.fileSentenceDropped : browserChatText.fileFactsOnly
: sentence ? browserChatText.factsAndSentence : dropped ? browserChatText.sentenceDropped : browserChatText.factsOnly;
return [summary.map(line => `- ${line}`).join('\n'), ...sentence ? [sentence] : [], `_${note}_`].join('\n\n');
}
@@ -351,8 +354,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
const run = { key: snapshot.key, cancelled: false, settled }; autoRun.current = run; lastAttempt.current = snapshot.key;
const valid = () => epoch.current === ticket && !run.cancelled && !stopRequested.current && selectionRef.current?.key === snapshot.key;
// Graph selections show their listed facts at once; the model only adds a sentence.
const summary = snapshot.reader ? [] : selectionSummary(snapshot.graph);
const writing = summary.length ? `${summary.map(line => `- ${line}`).join('\n')}\n\n_${sourceTargetOf(snapshot.graph) || carriedSource(snapshot.graph) ? browserChatText.writingSentence : browserChatText.readingFacts}_` : '';
const summary = snapshot.reader ? readerFacts(snapshot.reader) : selectionSummary(snapshot.graph);
const from = snapshot.reader ? 'file' : 'graph';
const writing = summary.length ? `${summary.map(line => `- ${line}`).join('\n')}\n\n_${snapshot.reader || sourceTargetOf(snapshot.graph) || carriedSource(snapshot.graph) ? browserChatText.writingSentence : browserChatText.readingFacts}_` : '';
setExplanation({ key: snapshot.key, label: snapshot.label, answer: writing, status: 'generating' });
setPhase('counting');
try {
@@ -394,7 +398,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
// One sentence beside the facts (two for reader code), and none that names what the evidence lacks.
const checked = result.status === 'generated' ? explanationSentence(result.markdown, packet, request.map(message => message.content).join('\n')) : {};
if (summary.length || (result.status === 'generated' && checked.dropped)) {
const markdown = summary.length ? groundedExplanation(summary, checked.sentence, checked.dropped !== undefined) : `_${browserChatText.explanationDropped}_`;
const markdown = summary.length ? groundedExplanation(summary, checked.sentence, checked.dropped !== undefined, from) : `_${browserChatText.explanationDropped}_`;
const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: markdown, status: 'complete', mode: 'interpretation', packet, grounded: summary.length > 0,
...shortened && checked.sentence ? { shortened, limit: { inputTokens: autoInput, outputTokens: autoOutput } } : {}, ...snapshot.evidence ? { evidence: snapshot.evidence } : {} };
remember(complete); setExplanation(complete);
@@ -79,6 +79,7 @@ function isPreparedEvidence(value: unknown): value is PreparedExplanationContext
return isObject(value) && typeof value.label === 'string' && typeof value.fallback === 'string'
&& typeof value.characterCount === 'number' && Number.isSafeInteger(value.characterCount) && value.characterCount >= 0
&& Array.isArray(value.limitations) && value.limitations.every(item => typeof item === 'string')
&& (value.fileFacts === undefined || (Array.isArray(value.fileFacts) && value.fileFacts.every(item => typeof item === 'string')))
&& Array.isArray(value.evidence) && value.evidence.every(item => isObject(item)
&& typeof item.id === 'string' && typeof item.text === 'string' && ['code', 'graph'].includes(String(item.source))
&& (item.location === undefined || (isObject(item.location)
@@ -125,5 +125,16 @@ describe('browser chat context', () => {
expect(system.content).toContain('never name tools, libraries, languages or values that are not in the file');
expect(user.content).toBe('was kannst du mir über dieses aktuelle File sagen');
});
it('gives the model the jobs, triggers and actions of a workflow counted from the file (K12)', () => {
const text = 'name: New contributor message\n\non:\n pull_request_target:\n types: [opened]\n\njobs:\n build:\n name: Hello new contributor\n runs-on: ubuntu-latest\n steps:\n - uses: actions/first-interaction@v1\n';
const workflow = { project: 'django-demo', path: '.github/workflows/new_contributor_pr.yml', status: 'ready' as const, source: { ...source, kind: 'file' as const,
project: 'django-demo', path: '.github/workflows/new_contributor_pr.yml', text } };
const [system] = buildChatMessages([], 'Wie viele Jobs gibt es?', undefined, [], workflow);
expect(system.content).toContain('Facts read from the file (counted, not guessed):\n- Workflow name: `New contributor message`.\n- Trigger: `pull_request_target` (types: opened).\n- 1 job: `build`');
// A marked part of the file is not the whole workflow: nothing is counted from it.
const marked = { ...workflow, source: { ...workflow.source, kind: 'selection' as const } };
expect(buildChatMessages([], 'Wie viele Jobs gibt es?', undefined, [], marked)[0].content).not.toContain('Facts read from the file');
});
});
+5 -2
View File
@@ -1,6 +1,8 @@
import type { BrowserChatMessage } from './browser-ai-runtime';
import type { ChatTopic } from './chat-context';
import { fileKind } from './file-kind';
import { readerFacts } from './workflow-facts';
import { workflowWords } from './strings';
/** An immutable snapshot, not a live reference to the reader selection. */
export interface BrowserChatAttachment {
@@ -81,10 +83,11 @@ export function snapshotReaderContext(context?: BrowserChatReaderContext): Brows
...(currentSource ? { source: currentSource } : {}) };
}
/** What the open file is and what an answer about it may say (K12). */
/** What the open file is, what is counted from it, and what an answer about it may say (K12). */
function readerRules(context: BrowserChatReaderContext): string {
const kind = fileKind(context.source?.path ?? context.path ?? '');
return `${kind ? `\nThe current file is a ${kind}.` : ''}\nAnswer from the current file and describe what it literally contains. Unless the user asks for it, do not write new code, scripts or commands, `
const facts = readerFacts(context);
return `${kind ? `\nThe current file is a ${kind}.` : ''}${facts.length ? `\n${workflowWords.heading}\n${facts.map(line => `- ${line}`).join('\n')}` : ''}\nAnswer from the current file and describe what it literally contains. Unless the user asks for it, do not write new code, scripts or commands, `
+ 'and never name tools, libraries, languages or values that are not in the file. Do not repeat the file; summarize it. If the file does not answer the question, say so.';
}
@@ -2,6 +2,7 @@ import { snapshotReaderContext, type BrowserChatContext, type BrowserChatReaderC
import { fairShares, readGalaxyEvidence, relationshipLine, scopeSentence, selectionSentence, sideLoaded, type GalaxyEvidence } from './galaxy-evidence';
import { relationshipWords } from './strings';
import { architectureFacts } from './architecture-evidence';
import { readerFacts } from './workflow-facts';
export interface ExplanationEvidence {
id: string;
@@ -19,6 +20,8 @@ export interface PreparedExplanationContext {
characterCount: number;
/** Set when the prompt could not name every related symbol the evidence carried: scope size and how many are named. */
capacity?: { nodes: number; edges: number; shown: number };
/** Facts counted from a whole open workflow file, whatever part of its text fits (K12). */
fileFacts?: string[];
}
const record = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -330,7 +333,8 @@ export function prepareExplanationContext(reader?: BrowserChatReaderContext,
break;
}
}
const fileFacts = readerFacts(snapshot);
const characterCount = label.length + fallback.length + evidence.reduce((count, item) => count + item.text.length, 0)
+ limitations.reduce((count, item) => count + item.length, 0);
return { label, evidence, limitations, fallback, characterCount, ...capacity ? { capacity } : {} };
+ limitations.reduce((count, item) => count + item.length, 0) + fileFacts.reduce((count, item) => count + item.length, 0);
return { label, evidence, limitations, fallback, characterCount, ...capacity ? { capacity } : {}, ...fileFacts.length ? { fileFacts } : {} };
}
@@ -1,6 +1,7 @@
import type { BrowserChatMessage } from './browser-ai-controller';
import type { PreparedExplanationContext } from './explanation-context';
import { fileKind } from './file-kind';
import { workflowWords } from './strings';
export const AUTO_INPUT_TOKENS = 1536;
export const AUTO_OUTPUT_TOKENS = 128;
@@ -13,6 +14,7 @@ export function formatExplanationEvidence(packet: PreparedExplanationContext): s
const kind = (path: string) => { const name = fileKind(path); return name ? `, a ${name}` : ''; };
return [packet.label, ...packet.evidence.map(item => item.source === 'code'
? `Source${item.location ? ` ${item.location.path}:${item.location.startLine}-${item.location.endLine}${kind(item.location.path)}` : ''}:\n${item.text}` : item.text),
...packet.fileFacts?.length ? [`${workflowWords.heading}\n${packet.fileFacts.map(line => `- ${line}`).join('\n')}`] : [],
...packet.limitations.map(limit => `Limit: ${limit}`)].join('\n\n');
}
+4 -1
View File
@@ -11,12 +11,15 @@ const KINDS: Readonly<Record<string, string>> = {
html: 'HTML document', css: 'CSS stylesheet', sql: 'SQL script', xml: 'XML data, not program code',
};
/** A GitHub Actions workflow, by where GitHub reads it from. */
export const isGithubWorkflow = (path: string): boolean => /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path);
export function fileKind(path: string): string | undefined {
const name = path.split('/').pop() ?? path;
const template = /^(.+)-tpl$/.exec(name);
const extension = (template?.[1] ?? name).split('.').slice(1).pop()?.toLowerCase();
const kind = extension ? KINDS[extension] : undefined;
if (!kind) return undefined;
if (/(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path)) return `GitHub Actions workflow (${kind})`;
if (isGithubWorkflow(path)) return `GitHub Actions workflow (${kind})`;
return template ? kind.replace(/ file$/, ' template') : kind;
}
+17
View File
@@ -148,6 +148,10 @@ export const browserChatText = {
factsOnly: 'Listed from the indexed graph; not generated by the model.',
sentenceDropped: "Listed from the indexed graph. The model's sentence named something the evidence does not show and was left out.",
explanationDropped: "The model's explanation named something the source does not show and was left out. Ask a question about the code instead.",
/** The same for facts read from an open workflow file (K12). */
fileFactsAndSentence: 'Facts read from the file; the text after them is generated by the model.',
fileFactsOnly: 'Read from the file; not generated by the model.',
fileSentenceDropped: "Read from the file. The model's text named something the file does not show and was left out.",
writingSentence: 'Reading the source; the model is adding one sentence…',
readingFacts: 'Listing the facts…',
/** Above the first question about another file or selection (K17). */
@@ -214,3 +218,16 @@ export const architectureWords = {
incoming: (items: readonly string[], total: number) => `Incoming from ${items.join(', ')}${total > items.length ? `; ${count(total - items.length)} more` : ''}.`,
outgoing: (items: readonly string[], total: number) => `Outgoing to ${items.join(', ')}${total > items.length ? `; ${count(total - items.length)} more` : ''}.`,
};
const counted = (value: number, one: string, many: string) => `${value.toLocaleString('en-US')} ${value === 1 ? one : many}`;
/** A GitHub Actions workflow as facts counted from its keys: for the prompt and the explanation card (K12). */
export const workflowWords = {
heading: 'Facts read from the file (counted, not guessed):',
name: (name: string) => `Workflow name: ${name}.`,
triggers: (items: readonly string[]) => `${items.length === 1 ? 'Trigger' : 'Triggers'}: ${items.join(', ')}.`,
trigger: (event: string, types: readonly string[]) => `${event}${types.length ? ` (types: ${types.join(', ')})` : ''}`,
jobs: (total: number, items: readonly string[]) => `${counted(total, 'job', 'jobs')}: ${items.join('; ')}${total > items.length ? `; +${(total - items.length).toLocaleString('en-US')} more` : ''}.`,
job: (id: string, name: string | undefined, runsOn: string | undefined, steps: number) =>
`${id} (${[name ? `"${name}"` : '', runsOn ? `runs on ${runsOn}` : '', counted(steps, 'step', 'steps')].filter(Boolean).join(', ')})`,
uses: (items: readonly string[]) => `Actions used: ${items.join(', ')}.`,
};
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { workflowFacts, workflowFactLines } from './workflow-facts';
/** .github/workflows/new_contributor_pr.yml of django-demo, as Explore shows it. */
const NEW_CONTRIBUTOR = `name: New contributor message
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
build:
name: Hello new contributor
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: \${{ secrets.GITHUB_TOKEN }}
pr-message: |
Hello! Thank you for your contribution
name: not a key
jobs: not a key either
- uses: not/a-step@v0
Welcome aboard!
`;
describe('facts of a GitHub Actions workflow (K12)', () => {
it('counts the one job, its trigger and the action it uses, not the text of a block value', () => {
expect(workflowFacts(NEW_CONTRIBUTOR)).toEqual({
name: 'New contributor message',
triggers: [{ event: 'pull_request_target', types: ['opened'] }],
jobs: [{ id: 'build', name: 'Hello new contributor', runsOn: 'ubuntu-latest', steps: 1 }],
uses: ['actions/first-interaction@v1'],
});
expect(workflowFactLines('.github/workflows/new_contributor_pr.yml', NEW_CONTRIBUTOR)).toEqual([
'Workflow name: `New contributor message`.',
'Trigger: `pull_request_target` (types: opened).',
'1 job: `build` ("Hello new contributor", runs on ubuntu-latest, 1 step).',
'Actions used: `actions/first-interaction@v1`.',
]);
});
it('reads scalar and list triggers, compact step lists and several jobs', () => {
const text = `name: "CI"
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
echo one
shell: bash
- name: Test
run: make test
docs:
uses: ./.github/workflows/docs.yml # reusable
`;
expect(workflowFacts(text)).toEqual({
name: 'CI',
triggers: [{ event: 'push' }, { event: 'pull_request' }],
jobs: [{ id: 'lint', runsOn: 'ubuntu-latest', steps: 3 }, { id: 'docs', steps: 0 }],
uses: ['actions/checkout@v4', './.github/workflows/docs.yml'],
});
expect(workflowFactLines('.github/workflows/ci.yml', text)).toContain('2 jobs: `lint` (runs on ubuntu-latest, 3 steps); `docs` (0 steps).');
expect(workflowFacts('on: push\njobs: {}\n')?.triggers).toEqual([{ event: 'push' }]);
});
it('says nothing for other files or text that is not a workflow', () => {
expect(workflowFactLines('docker-compose.yml', NEW_CONTRIBUTOR)).toEqual([]);
expect(workflowFactLines('.github/workflows/broken.yml', 'just text')).toEqual([]);
expect(workflowFacts('')).toBeUndefined();
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { BrowserChatReaderContext } from './chat-model';
import { isGithubWorkflow } from './file-kind';
import { workflowWords as words } from './strings';
/** A GitHub Actions workflow as facts counted from its keys. The small model answered
* "Es gibt 3 Jobs" for a file with one, and read the trigger type "opened" as a branch
* name (K12): jobs, triggers and actions are counted here, not by the model. No YAML
* library: only the plain block layout workflows are written in. */
export interface WorkflowFacts {
name?: string;
triggers: { event: string; types?: string[] }[];
jobs: { id: string; name?: string; runsOn?: string; steps: number }[];
uses: string[];
}
/** One line of the file: where it starts, where its key starts (after "- "), key and value. */
interface Line { indent: number; keyIndent: number; dash: boolean; key?: string; value: string }
const unquote = (value: string) => value.replace(/^(["'])(.*)\1$/, '$2');
/** A plain value without its trailing comment or quotes. */
const plain = (value: string) => unquote(value.replace(/\s+#.*$/, '').trim());
/** "[push, pull_request]" or "push"; "{}" and "[]" are empty. */
const flow = (value: string): string[] => {
const list = /^\[(.*)\]$/.exec(value);
if (!list) return value && value !== '{}' ? [value] : [];
return list[1].split(',').map(plain).filter(Boolean);
};
function linesOf(text: string): Line[] {
const lines: Line[] = [];
// The lines of a block value ("key: |") are text, not keys.
let block: number | undefined;
for (const raw of text.split(/\r?\n/)) {
const content = raw.trimStart();
const indent = raw.length - content.length;
if (block !== undefined) {
if (!content || indent > block) continue;
block = undefined;
}
if (!content || content.startsWith('#')) continue;
const dash = /^-(?:\s|$)/.test(content);
const item = dash ? content.slice(1).trimStart() : content;
const keyIndent = dash ? indent + content.length - item.length : indent;
const match = /^("[^"]*"|'[^']*'|[^\s"'#][^:#]*?)\s*:(?:\s+(.*))?$/.exec(item);
const value = match ? match[2] ?? '' : item;
if (/^[|>][-+0-9]*(?:\s+#.*)?$/.test(value.trim())) block = keyIndent;
lines.push({ indent, keyIndent, dash, key: match ? unquote(match[1].trim()) : undefined, value: plain(value) });
}
return lines;
}
/** Every line inside the value of line `at`; a list may start at its key's own indent. */
function inside(lines: readonly Line[], at: number): number[] {
const parent = lines[at], found: number[] = [];
for (let index = at + 1; index < lines.length; index++) {
const line = lines[index];
if (line.keyIndent <= parent.keyIndent || (line.dash && line.indent < parent.keyIndent)) break;
found.push(index);
}
return found;
}
/** The direct children of line `at`: the lines inside it at their smallest key indent. */
function children(lines: readonly Line[], at: number): number[] {
const found = inside(lines, at);
const level = Math.min(...found.map(index => lines[index].keyIndent));
return found.filter(index => lines[index].keyIndent === level);
}
export function workflowFacts(text: string): WorkflowFacts | undefined {
const lines = linesOf(text);
if (!lines.length) return undefined;
const level = Math.min(...lines.map(line => line.keyIndent));
const top = (key: string) => lines.findIndex(line => line.keyIndent === level && !line.dash && line.key === key);
const on = top('on'), jobs = top('jobs'), name = top('name');
if (on < 0 || jobs < 0) return undefined;
const triggers = lines[on].value ? flow(lines[on].value).map(event => ({ event })) : children(lines, on).flatMap(index => {
const event = lines[index].key ?? lines[index].value;
const at = children(lines, index).find(child => lines[child].key === 'types');
const types = at === undefined ? [] : lines[at].value ? flow(lines[at].value) : children(lines, at).map(child => lines[child].value).filter(Boolean);
return event ? [{ event, ...types.length ? { types } : {} }] : [];
});
const jobList = children(lines, jobs).filter(index => lines[index].key).map(index => {
const own = children(lines, index);
const field = (key: string) => { const at = own.find(child => lines[child].key === key); return at === undefined ? undefined : lines[at].value || undefined; };
const stepsAt = own.find(child => lines[child].key === 'steps');
const items = stepsAt === undefined ? [] : inside(lines, stepsAt).filter(child => lines[child].dash);
const first = Math.min(...items.map(child => lines[child].indent));
const jobName = field('name'), runsOn = field('runs-on');
return { id: lines[index].key!, ...jobName ? { name: jobName } : {}, ...runsOn ? { runsOn } : {}, steps: items.filter(child => lines[child].indent === first).length };
});
const uses = [...new Set(inside(lines, jobs).filter(index => lines[index].key === 'uses' && lines[index].value).map(index => lines[index].value))];
return { ...name >= 0 && lines[name].value ? { name: lines[name].value } : {}, triggers, jobs: jobList, uses };
}
const quote = (value: string) => `\`${value.replace(/`/g, "'").slice(0, 120)}\``;
const LISTED = 8;
/** The facts of a workflow file as sentences; nothing for other files. */
export function workflowFactLines(path: string, text: string): string[] {
const facts = isGithubWorkflow(path) ? workflowFacts(text) : undefined;
if (!facts) return [];
const lines: string[] = [];
if (facts.name) lines.push(words.name(quote(facts.name)));
if (facts.triggers.length) lines.push(words.triggers(facts.triggers.slice(0, LISTED).map(item => words.trigger(quote(item.event), item.types ?? []))));
lines.push(words.jobs(facts.jobs.length, facts.jobs.slice(0, LISTED).map(job => words.job(quote(job.id), job.name?.slice(0, 120), job.runsOn?.slice(0, 60), job.steps))));
if (facts.uses.length) lines.push(words.uses(facts.uses.slice(0, LISTED).map(quote)));
return lines;
}
/** Only a whole open file is counted: a marked part or a cut excerpt is not the workflow. */
export function readerFacts(reader: BrowserChatReaderContext | undefined): string[] {
const source = reader?.source;
return source?.kind === 'file' && !source.partial ? workflowFactLines(source.path, source.text) : [];
}