@delfini/action-core 0.1.0

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 (39) hide show
  1. package/README.md +37 -0
  2. package/dist/adapters/factory.d.ts +3 -0
  3. package/dist/adapters/factory.d.ts.map +1 -0
  4. package/dist/adapters/factory.js +2 -0
  5. package/dist/adapters/single-call/model.d.ts +4 -0
  6. package/dist/adapters/single-call/model.d.ts.map +1 -0
  7. package/dist/adapters/single-call/model.js +46 -0
  8. package/dist/adapters/single-call/orchestrator.d.ts +10 -0
  9. package/dist/adapters/single-call/orchestrator.d.ts.map +1 -0
  10. package/dist/adapters/single-call/orchestrator.js +97 -0
  11. package/dist/adapters/single-call/prompt.md +360 -0
  12. package/dist/analysis-input.d.ts +18 -0
  13. package/dist/analysis-input.d.ts.map +1 -0
  14. package/dist/analysis-input.js +60 -0
  15. package/dist/diff-builder.d.ts +16 -0
  16. package/dist/diff-builder.d.ts.map +1 -0
  17. package/dist/diff-builder.js +35 -0
  18. package/dist/doc-exclusion.d.ts +17 -0
  19. package/dist/doc-exclusion.d.ts.map +1 -0
  20. package/dist/doc-exclusion.js +85 -0
  21. package/dist/doc-reader.d.ts +34 -0
  22. package/dist/doc-reader.d.ts.map +1 -0
  23. package/dist/doc-reader.js +172 -0
  24. package/dist/github-client-shared.d.ts +27 -0
  25. package/dist/github-client-shared.d.ts.map +1 -0
  26. package/dist/github-client-shared.js +240 -0
  27. package/dist/index.d.ts +19 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +20 -0
  30. package/dist/pipeline-inputs.d.ts +23 -0
  31. package/dist/pipeline-inputs.d.ts.map +1 -0
  32. package/dist/pipeline-inputs.js +42 -0
  33. package/dist/ports/orchestrator.d.ts +5 -0
  34. package/dist/ports/orchestrator.d.ts.map +1 -0
  35. package/dist/ports/orchestrator.js +1 -0
  36. package/dist/smart-skip.d.ts +10 -0
  37. package/dist/smart-skip.d.ts.map +1 -0
  38. package/dist/smart-skip.js +85 -0
  39. package/package.json +41 -0
@@ -0,0 +1,240 @@
1
+ import * as core from '@actions/core';
2
+ import * as github from '@actions/github';
3
+ import { readDocsAtHeadViaGitTrees } from './doc-reader.js';
4
+ export function getPrContext() {
5
+ const { payload, repo } = github.context;
6
+ if (payload.pull_request) {
7
+ return {
8
+ owner: repo.owner,
9
+ repo: repo.repo,
10
+ pullNumber: payload.pull_request.number,
11
+ headSha: payload.pull_request.head.sha,
12
+ baseSha: payload.pull_request.base.sha,
13
+ };
14
+ }
15
+ if (payload.issue?.pull_request && payload.comment) {
16
+ const prUrl = payload.issue.pull_request.url;
17
+ return {
18
+ owner: repo.owner,
19
+ repo: repo.repo,
20
+ pullNumber: payload.issue.number,
21
+ headSha: '',
22
+ baseSha: '',
23
+ ...extractShasFromIssueComment(prUrl),
24
+ };
25
+ }
26
+ throw new Error(`Unsupported event: expected pull_request or issue_comment on a PR, got ${github.context.eventName}`);
27
+ }
28
+ export async function listChangedFiles(octokit, ctx) {
29
+ const files = [];
30
+ let page = 1;
31
+ while (true) {
32
+ const { data } = await octokit.rest.pulls.listFiles({
33
+ owner: ctx.owner,
34
+ repo: ctx.repo,
35
+ pull_number: ctx.pullNumber,
36
+ per_page: 100,
37
+ page,
38
+ });
39
+ if (data.length === 0) {
40
+ break;
41
+ }
42
+ for (const file of data) {
43
+ files.push({
44
+ filename: file.filename,
45
+ status: file.status,
46
+ patch: file.patch,
47
+ });
48
+ }
49
+ if (data.length < 100) {
50
+ break;
51
+ }
52
+ page++;
53
+ }
54
+ return files;
55
+ }
56
+ export async function getFileContent(octokit, ctx, path, ref) {
57
+ try {
58
+ const { data } = await octokit.rest.repos.getContent({
59
+ owner: ctx.owner,
60
+ repo: ctx.repo,
61
+ path,
62
+ ref,
63
+ });
64
+ if (Array.isArray(data) || data.type !== 'file' || !data.content) {
65
+ return null;
66
+ }
67
+ return Buffer.from(data.content, 'base64').toString('utf-8');
68
+ }
69
+ catch (error) {
70
+ if (typeof error === 'object' &&
71
+ error !== null &&
72
+ 'status' in error &&
73
+ error.status === 404) {
74
+ return null;
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+ export async function readDocs(octokit, ctx, docScope, options = {}) {
80
+ // v6.1 — walk at the PR head, not the merge-base. After Approve and Commit
81
+ // splices doc updates onto the PR branch, the head ref carries those updates;
82
+ // walking at base would re-detect the same drift forever (and would force
83
+ // FR88f to keep creating new pending findings every re-run because section
84
+ // anchors are LLM-non-deterministic). Reading at head closes that loop:
85
+ // Approve and Commit -> head advances -> next walk sees the updated doc ->
86
+ // PASS verdict -> Stream 4a auto-supersede -> review resolves.
87
+ //
88
+ // Story 3.12 (ADR-2026-06-01) — Full mode now reads via ONE recursive
89
+ // git-trees call + the shared `isFileInDocScope` matcher (picomatch@4),
90
+ // exactly as Lite mode does (Story P2.6). `docScope` is the canonical
91
+ // `string[]` (multi-path/glob). The old per-directory `getContent` walk
92
+ // (`readDocsFromPath`) is retired in this story.
93
+ return readDocsAtHeadViaGitTrees(octokit, ctx.owner, ctx.repo, docScope, ctx.headSha, options);
94
+ }
95
+ // v6.0 thin top-level PR comment (Story 3.11). Uses issues.createComment /
96
+ // updateComment (NOT pulls.createReview) — replaces the pre-v6.0 Request
97
+ // Changes / Approve review primitive on success paths. Idempotent on `Bot`
98
+ // author + label-prefix marker — re-runs update the same comment in place.
99
+ //
100
+ // The hidden HTML marker (DELFINI_PR_COMMENT_MARKER) is appended to every
101
+ // v6.0 thin template body so the matcher can be specific without depending
102
+ // on visible body text. A foreign Bot whose comment happens to start with
103
+ // `**` (Dependabot, Renovate, etc.) is NOT matched because they don't carry
104
+ // the marker. The matcher uses `body.includes(marker)` not `startsWith` so
105
+ // the marker can ride at the end of the body without affecting layout.
106
+ //
107
+ // Pagination: caps at 5 pages (500 comments) before warn-and-create — same
108
+ // shape as postOrUpdateReview's 100-cap warn pattern. PRs with >500 comments
109
+ // are pathological; warn-and-create produces a benign duplicate at worst.
110
+ const PR_COMMENT_PAGINATION_PAGE_CAP = 5;
111
+ const PR_COMMENT_PAGINATION_PER_PAGE = 100;
112
+ // Hidden HTML marker — invisible in rendered markdown, byte-stable in source.
113
+ // Keep this constant in sync with the marker appended by emitStreamDecision.
114
+ export const DELFINI_PR_COMMENT_MARKER = '<!-- delfini-pr-comment -->';
115
+ export async function postOrUpdatePrComment(octokit, ctx, body, options) {
116
+ let existingId = null;
117
+ let extraMatches = 0;
118
+ let pagesScanned = 0;
119
+ for (let page = 1; page <= PR_COMMENT_PAGINATION_PAGE_CAP; page++) {
120
+ const { data } = await octokit.rest.issues.listComments({
121
+ owner: ctx.owner,
122
+ repo: ctx.repo,
123
+ issue_number: ctx.pullNumber,
124
+ per_page: PR_COMMENT_PAGINATION_PER_PAGE,
125
+ page,
126
+ });
127
+ pagesScanned += 1;
128
+ const matches = data.filter((c) => c.user?.type === 'Bot' && (c.body ?? '').includes(options.marker));
129
+ if (matches.length > 0) {
130
+ if (existingId === null) {
131
+ existingId = matches[0].id;
132
+ extraMatches = matches.length - 1;
133
+ }
134
+ else {
135
+ extraMatches += matches.length;
136
+ }
137
+ // Continue paginating to count duplicates across pages for the warn-on-many path.
138
+ // Stop early once we've scanned enough to be confident — first match is the
139
+ // one we'll update; extras just produce a warning.
140
+ if (data.length < PR_COMMENT_PAGINATION_PER_PAGE)
141
+ break;
142
+ continue;
143
+ }
144
+ if (data.length < PR_COMMENT_PAGINATION_PER_PAGE)
145
+ break;
146
+ }
147
+ if (extraMatches > 0) {
148
+ core.warning(`Delfini: ${extraMatches + 1} matching Delfini PR comments found — updating the first; consider manual cleanup of duplicates.`);
149
+ }
150
+ if (existingId === null &&
151
+ pagesScanned === PR_COMMENT_PAGINATION_PAGE_CAP) {
152
+ core.warning(`Delfini: PR comment scan capped at ${PR_COMMENT_PAGINATION_PAGE_CAP * PR_COMMENT_PAGINATION_PER_PAGE} comments — idempotency may misfire on this PR.`);
153
+ }
154
+ if (existingId !== null) {
155
+ try {
156
+ await octokit.rest.issues.updateComment({
157
+ owner: ctx.owner,
158
+ repo: ctx.repo,
159
+ comment_id: existingId,
160
+ body,
161
+ });
162
+ return;
163
+ }
164
+ catch (error) {
165
+ // 404 = comment was deleted between listComments and updateComment
166
+ // (manual delete by reviewer, race with another tool). Fall through to
167
+ // createComment so the PR doesn't end up with no Delfini comment at all.
168
+ if (isHttpStatus(error, 404)) {
169
+ core.warning(`Delfini: PR comment #${existingId} disappeared between scan and update — creating a fresh comment.`);
170
+ }
171
+ else {
172
+ throw error;
173
+ }
174
+ }
175
+ }
176
+ await octokit.rest.issues.createComment({
177
+ owner: ctx.owner,
178
+ repo: ctx.repo,
179
+ issue_number: ctx.pullNumber,
180
+ body,
181
+ });
182
+ }
183
+ function isHttpStatus(error, status) {
184
+ return (typeof error === 'object' &&
185
+ error !== null &&
186
+ 'status' in error &&
187
+ error.status === status);
188
+ }
189
+ // 403 (read-only token) and 404 (resource not visible) both signal the
190
+ // workflow lacks permission to write a check or PR comment — typically a fork
191
+ // PR. Callers tolerate these (warn + continue) rather than crashing the run.
192
+ // Story P2.2 (AC7) — moved here from pipeline.ts so the Full and Lite
193
+ // pipelines share one predicate.
194
+ export function isForbiddenError(error) {
195
+ return isHttpStatus(error, 403) || isHttpStatus(error, 404);
196
+ }
197
+ export async function createCheckStatus(octokit, ctx, conclusion, title, summary, detailsUrl) {
198
+ // Conditional spread keeps the request body free of an explicit
199
+ // `details_url: undefined` — Octokit treats absent and `undefined` fields
200
+ // differently for some endpoints, and we want GitHub's default Details
201
+ // affordance (the built-in check_run page) when no URL is provided.
202
+ //
203
+ // 'in_progress' is the sentinel for "attention needed, static yellow icon":
204
+ // we use the legacy Commit Statuses API with state='pending' because that
205
+ // renders a STATIC yellow dot (no spinner). The check_runs API has no
206
+ // conclusion that renders static yellow — status='in_progress' animates,
207
+ // conclusion='action_required' renders red, and conclusion='neutral' renders
208
+ // grey. Chromatic and other "needs review" integrations also use commit
209
+ // statuses for this exact reason. The 140-char description cap is enforced
210
+ // by GitHub; truncate defensively so a long title doesn't 422.
211
+ if (conclusion === 'in_progress') {
212
+ const description = title.length > 140 ? title.slice(0, 139) + '…' : title;
213
+ await octokit.rest.repos.createCommitStatus({
214
+ owner: ctx.owner,
215
+ repo: ctx.repo,
216
+ sha: ctx.headSha,
217
+ state: 'pending',
218
+ context: 'Delfini Docs Drift Check',
219
+ description,
220
+ ...(detailsUrl !== undefined ? { target_url: detailsUrl } : {}),
221
+ });
222
+ return;
223
+ }
224
+ await octokit.rest.checks.create({
225
+ owner: ctx.owner,
226
+ repo: ctx.repo,
227
+ head_sha: ctx.headSha,
228
+ name: 'Delfini Docs Drift Check',
229
+ status: 'completed',
230
+ conclusion,
231
+ output: { title, summary },
232
+ ...(detailsUrl !== undefined ? { details_url: detailsUrl } : {}),
233
+ });
234
+ }
235
+ function extractShasFromIssueComment(_prUrl) {
236
+ // SHAs are not available on issue_comment events directly.
237
+ // The caller must fetch them from the PR API after context creation.
238
+ // Returning empty strings signals that SHAs need to be resolved.
239
+ return {};
240
+ }
@@ -0,0 +1,19 @@
1
+ export { readDocsViaGitTrees, readDocsAtHeadViaGitTrees } from './doc-reader.js';
2
+ export type { DocFile, DocsReadResult, ReadDocsOptions, ScopeSource } from './doc-reader.js';
3
+ export { parseFrontMatter, stripFrontMatter } from './doc-exclusion.js';
4
+ export type { ExcludedDoc, ExclusionReason, FrontMatterResult } from './doc-exclusion.js';
5
+ export { buildAnalysisInput } from './analysis-input.js';
6
+ export type { BuildAnalysisInputOptions } from './analysis-input.js';
7
+ export { classifyPr } from './smart-skip.js';
8
+ export type { SmartSkipOptions, SmartSkipResult } from './smart-skip.js';
9
+ export { buildUnifiedDiff } from './diff-builder.js';
10
+ export { DELFINI_PR_COMMENT_MARKER, createCheckStatus, getFileContent, getPrContext, isForbiddenError, listChangedFiles, postOrUpdatePrComment, readDocs, } from './github-client-shared.js';
11
+ export type { ChangedFile, PrContext } from './github-client-shared.js';
12
+ export { readPipelineInputs } from './pipeline-inputs.js';
13
+ export type { Enforcement, PipelineDeps, PipelineInputs } from './pipeline-inputs.js';
14
+ export { createOrchestrator } from './adapters/factory.js';
15
+ export { SingleCallOrchestrator, loadTemplate as loadPromptTemplate, } from './adapters/single-call/orchestrator.js';
16
+ export { createChatModel } from './adapters/single-call/model.js';
17
+ export type { LLMProvider } from './adapters/single-call/model.js';
18
+ export type { AnalysisOrchestrator } from './ports/orchestrator.js';
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAA;AAChF,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAE5F,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACvE,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAEzF,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,YAAY,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAA;AAEpE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAExE,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAEpD,OAAO,EACL,yBAAyB,EACzB,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,QAAQ,GACT,MAAM,2BAA2B,CAAA;AAClC,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AAEvE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AACzD,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAErF,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAC1D,OAAO,EACL,sBAAsB,EACtB,YAAY,IAAI,kBAAkB,GACnC,MAAM,wCAAwC,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AACjE,YAAY,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAA;AAElE,YAAY,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // @delfini/action-core — single barrel (project rule: named exports only;
2
+ // barrel only at packages/*/src/index.ts).
3
+ //
4
+ // Story P3.9.2a (Lite/Full artifact split, Mechanism A): the shared
5
+ // analysis-pipeline core of the Delfini GitHub Action, consumed by the public
6
+ // Lite artifact (apps/action) and the Full artifact (apps/action-full;
7
+ // re-homed into delfini-web at P3.9.4) — algorithm parity by construction.
8
+ //
9
+ // V1 stability posture: internal shared core, published for transparency and
10
+ // delfini-web consumption — no semver API-stability promise (see README.md).
11
+ export { readDocsViaGitTrees, readDocsAtHeadViaGitTrees } from './doc-reader.js';
12
+ export { parseFrontMatter, stripFrontMatter } from './doc-exclusion.js';
13
+ export { buildAnalysisInput } from './analysis-input.js';
14
+ export { classifyPr } from './smart-skip.js';
15
+ export { buildUnifiedDiff } from './diff-builder.js';
16
+ export { DELFINI_PR_COMMENT_MARKER, createCheckStatus, getFileContent, getPrContext, isForbiddenError, listChangedFiles, postOrUpdatePrComment, readDocs, } from './github-client-shared.js';
17
+ export { readPipelineInputs } from './pipeline-inputs.js';
18
+ export { createOrchestrator } from './adapters/factory.js';
19
+ export { SingleCallOrchestrator, loadTemplate as loadPromptTemplate, } from './adapters/single-call/orchestrator.js';
20
+ export { createChatModel } from './adapters/single-call/model.js';
@@ -0,0 +1,23 @@
1
+ import type { GitHub } from '@actions/github/lib/utils';
2
+ import type { AnalysisOrchestrator } from './ports/orchestrator.js';
3
+ type Octokit = InstanceType<typeof GitHub>;
4
+ export type Enforcement = 'required' | 'warning';
5
+ export interface PipelineInputs {
6
+ docScope: string[];
7
+ enforcement: Enforcement;
8
+ githubToken: string;
9
+ /**
10
+ * Story P3.7.2 / FR151 — opt-in diff pre-filter. Default `false` — the
11
+ * assembled analysis input is byte-identical to the pre-story baseline.
12
+ * Optional in the type so test fixtures that build a minimal PipelineInputs
13
+ * keep compiling; `readPipelineInputs` always sets it explicitly.
14
+ */
15
+ enableDiffPreFilter?: boolean;
16
+ }
17
+ export interface PipelineDeps {
18
+ octokit: Octokit;
19
+ orchestrator?: AnalysisOrchestrator;
20
+ }
21
+ export declare function readPipelineInputs(): PipelineInputs;
22
+ export {};
23
+ //# sourceMappingURL=pipeline-inputs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline-inputs.d.ts","sourceRoot":"","sources":["../src/pipeline-inputs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAA;AAGvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA;AAUnE,KAAK,OAAO,GAAG,YAAY,CAAC,OAAO,MAAM,CAAC,CAAA;AAE1C,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,SAAS,CAAA;AAEhD,MAAM,WAAW,cAAc;IAS7B,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,WAAW,EAAE,WAAW,CAAA;IACxB,WAAW,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,YAAY,CAAC,EAAE,oBAAoB,CAAA;CACpC;AAED,wBAAgB,kBAAkB,IAAI,cAAc,CA2CnD"}
@@ -0,0 +1,42 @@
1
+ import * as core from '@actions/core';
2
+ import { normalizeDocScope } from '@delfini/drift-engine';
3
+ export function readPipelineInputs() {
4
+ // Story P2.7 — Lite-mode doc-scope input is `doc_scope` (canonical name
5
+ // shared with the drift-engine algebra, CLI, FR88g API response, DB column,
6
+ // and Web settings). ADR-2026-06-01 / Story P2.6 — `doc_scope` accepts a
7
+ // newline- or comma-delimited list (action.yml inputs are strings only). A
8
+ // single bare path still works. The split list is fed through
9
+ // `normalizeDocScope` from `@delfini/drift-engine` (POSIX, trim, trailing-
10
+ // slash strip, dedupe, `./` / `..` resolution all handled there — do NOT
11
+ // re-implement here).
12
+ //
13
+ // The default `['docs/']` keeps Lite's user-observable default identical to
14
+ // pre-P2.6 (single string `'docs/'`) — only the type changes. The defensive
15
+ // collapse-to-empty fallback covers the user-types-",,,," case so an
16
+ // accidentally-empty normalized list never silently turns into a zero-scope
17
+ // run (which would skip every PR via FR57(b)).
18
+ const rawDocScope = core.getInput('doc_scope');
19
+ const entries = rawDocScope.length > 0 ? rawDocScope.split(/[\n,]/) : ['docs/'];
20
+ const normalized = normalizeDocScope(entries);
21
+ // A non-empty input that normalises to nothing (e.g. " ", ",,," , "\n\n")
22
+ // is almost certainly a typo, not an intentional default request. Warn so the
23
+ // operator can tell "I omitted doc_scope → default" apart from "my doc_scope
24
+ // collapsed → default" before silently analysing `docs/` (Story P2.6).
25
+ if (rawDocScope.length > 0 && normalized.length === 0) {
26
+ core.warning(`doc_scope "${rawDocScope}" contains no valid doc-scope entries after ` +
27
+ 'normalisation — falling back to the default "docs/". Check for stray ' +
28
+ 'delimiters or whitespace-only entries.');
29
+ }
30
+ // Fallback flows through `normalizeDocScope` so the collapse-to-default path
31
+ // yields the exact same value (`['docs']`) as the omitted-input path — no
32
+ // trailing-slash inconsistency between the two default routes.
33
+ const docScope = normalized.length > 0 ? normalized : normalizeDocScope(['docs/']);
34
+ const rawEnforcement = (core.getInput('enforcement') || 'warning').toLowerCase();
35
+ const enforcement = rawEnforcement === 'required' ? 'required' : 'warning';
36
+ const githubToken = process.env.GITHUB_TOKEN ?? core.getInput('github_token');
37
+ // Story P3.7.2 / FR151 — `enable_diff_prefilter` action.yml input. Default
38
+ // false: any value other than the literal "true" (case-insensitive) leaves
39
+ // the gate off so the pre-story behaviour is the path of least resistance.
40
+ const enableDiffPreFilter = core.getInput('enable_diff_prefilter').toLowerCase() === 'true';
41
+ return { docScope, enforcement, githubToken, enableDiffPreFilter };
42
+ }
@@ -0,0 +1,5 @@
1
+ import type { AnalysisInput, AnalysisResult } from '@delfini/drift-engine';
2
+ export interface AnalysisOrchestrator {
3
+ analyze(input: AnalysisInput): Promise<AnalysisResult>;
4
+ }
5
+ //# sourceMappingURL=orchestrator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/ports/orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAE1E,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAAA;CACvD"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ export interface SmartSkipOptions {
2
+ docScope: string[];
3
+ skipTestFiles?: boolean;
4
+ }
5
+ export interface SmartSkipResult {
6
+ shouldSkip: boolean;
7
+ reason: string;
8
+ }
9
+ export declare function classifyPr(changedFiles: string[], options: SmartSkipOptions): SmartSkipResult;
10
+ //# sourceMappingURL=smart-skip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smart-skip.d.ts","sourceRoot":"","sources":["../src/smart-skip.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,gBAAgB;IAS/B,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,OAAO,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;CACf;AA8DD,wBAAgB,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,gBAAgB,GAAG,eAAe,CA+C7F"}
@@ -0,0 +1,85 @@
1
+ import { isFileInDocScope } from '@delfini/drift-engine';
2
+ const categoryLabels = {
3
+ dependency: 'dependency update',
4
+ ci: 'CI config change',
5
+ generated: 'generated file',
6
+ node_modules: 'node_modules change',
7
+ test: 'test file change',
8
+ };
9
+ const DEPENDENCY_BASENAMES = new Set([
10
+ 'package.json',
11
+ 'pnpm-lock.yaml',
12
+ 'package-lock.json',
13
+ 'yarn.lock',
14
+ ]);
15
+ function basename(filePath) {
16
+ const parts = filePath.split('/');
17
+ return parts[parts.length - 1] ?? filePath;
18
+ }
19
+ function classifyFile(filePath, skipTestFiles) {
20
+ if (filePath.includes('node_modules/')) {
21
+ return 'node_modules';
22
+ }
23
+ if (DEPENDENCY_BASENAMES.has(basename(filePath))) {
24
+ return 'dependency';
25
+ }
26
+ if (filePath.startsWith('.github/') && (filePath.endsWith('.yml') || filePath.endsWith('.yaml'))) {
27
+ return 'ci';
28
+ }
29
+ if (filePath.endsWith('.generated.ts') || filePath.endsWith('.gen.ts')) {
30
+ return 'generated';
31
+ }
32
+ if (skipTestFiles &&
33
+ (filePath.endsWith('.test.ts') || filePath.endsWith('.test.tsx') || filePath.endsWith('.spec.ts'))) {
34
+ return 'test';
35
+ }
36
+ return null;
37
+ }
38
+ function formatReason(counts) {
39
+ const parts = [];
40
+ for (const [category, count] of counts) {
41
+ const label = categoryLabels[category];
42
+ parts.push(`${count} ${label}${count > 1 ? 's' : ''}`);
43
+ }
44
+ return parts.join(', ');
45
+ }
46
+ export function classifyPr(changedFiles, options) {
47
+ if (changedFiles.length === 0) {
48
+ return { shouldSkip: true, reason: 'No changed files detected' };
49
+ }
50
+ const skipTestFiles = options.skipTestFiles ?? false;
51
+ const counts = new Map();
52
+ let docInScopeCount = 0;
53
+ for (const filePath of changedFiles) {
54
+ // Story P2.6 — shared in-scope predicate (picomatch@4 dialect via
55
+ // `@delfini/drift-engine`). Replaces the legacy private `isDocFile`
56
+ // prefix check so smart-skip and the Lite reader's git-trees matcher
57
+ // can never silently disagree on the same scope.
58
+ if (isFileInDocScope(filePath, options.docScope)) {
59
+ docInScopeCount += 1;
60
+ continue;
61
+ }
62
+ const category = classifyFile(filePath, skipTestFiles);
63
+ if (category === null) {
64
+ return { shouldSkip: false, reason: 'Business-logic changes detected' };
65
+ }
66
+ counts.set(category, (counts.get(category) ?? 0) + 1);
67
+ }
68
+ let structurallyUninterestingCount = 0;
69
+ for (const count of counts.values()) {
70
+ structurallyUninterestingCount += count;
71
+ }
72
+ // FR57(b) v6.1 — every changed file is a doc-in-scope; smart-skip fires.
73
+ if (docInScopeCount > 0 && structurallyUninterestingCount === 0) {
74
+ const noun = docInScopeCount === 1 ? 'change' : 'changes';
75
+ return { shouldSkip: true, reason: `${docInScopeCount} doc-only ${noun} in doc scope` };
76
+ }
77
+ // FR57(a) — pure structurally-uninteresting changes; existing skip path.
78
+ if (docInScopeCount === 0) {
79
+ return { shouldSkip: true, reason: formatReason(counts) };
80
+ }
81
+ // Mixed doc-in-scope + structurally-uninteresting. The two skip checks
82
+ // are independent (FR57 v6.1) — a mixed PR satisfies neither, so analysis
83
+ // runs.
84
+ return { shouldSkip: false, reason: 'Mixed doc and non-doc changes detected' };
85
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@delfini/action-core",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "Apache-2.0",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "description": "Shared analysis-pipeline core of the Delfini GitHub Action (doc reader, smart-skip, analysis-input assembly, single-call orchestrator adapters, shared GitHub client, pipeline input reader). Internal shared core published for transparency and delfini-web consumption — no semver API-stability promise in V1.",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "dependencies": {
22
+ "@actions/core": "^1.11.1",
23
+ "@actions/github": "^6.0.0",
24
+ "@langchain/anthropic": "^1.3.29",
25
+ "@langchain/core": "^1.1.45",
26
+ "@langchain/openai": "^1.4.5",
27
+ "gray-matter": "^4.0.3",
28
+ "@delfini/drift-engine": "0.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.10.2",
32
+ "typescript": "^5.7.2",
33
+ "vitest": "^3.0.5"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -b && node scripts/copy-prompt.mjs",
37
+ "typecheck": "tsc --noEmit",
38
+ "lint": "eslint src",
39
+ "test": "vitest run"
40
+ }
41
+ }