diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..c9d285dbd8 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Format maintained frontend sources with Vite+. +ffba2136dbc88ef6a570398184c355eaeed5424e diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ae67c4dd85..c9fc16277e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,26 @@ version: 2 updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + vite-plus: + patterns: + - "vite-plus" + - "vite" + - "@voidzero-dev/vite-plus-core" + - "vitest" + - "@vitest/*" + - package-ecosystem: "npm" + directory: "/skeletons/hypervel" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + versioning-strategy: "increase-if-necessary" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/scripts/submission-cleanup.cjs b/.github/scripts/submission-cleanup.cjs index 230e53555c..ed49a1bab1 100644 --- a/.github/scripts/submission-cleanup.cjs +++ b/.github/scripts/submission-cleanup.cjs @@ -11,7 +11,8 @@ module.exports = async function cleanup({ github, context, core, now = new Date( async function permissionFor(login) { if (!permissions.has(login)) { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - ...repository, username: login, + ...repository, + username: login, }); permissions.set(login, data); } @@ -23,22 +24,27 @@ module.exports = async function cleanup({ github, context, core, now = new Date( if (!user || user.type === 'Bot') return false; const permission = await permissionFor(user.login); - return ['admin', 'write', 'maintain'].includes(permission.permission) - || (allowTriage && permission.role_name === 'triage'); + return ( + ['admin', 'write', 'maintain'].includes(permission.permission) || + (allowTriage && permission.role_name === 'triage') + ); } function isExempt(item) { - return item.labels.some(label => label.name === EXEMPT_LABEL); + return item.labels.some((label) => label.name === EXEMPT_LABEL); } async function commentsFor(number) { return github.paginate(github.rest.issues.listComments, { - ...repository, issue_number: number, per_page: 100, + ...repository, + issue_number: number, + per_page: 100, }); } async function authorEditAndDraftStart(item) { - const { node } = await github.graphql(` + const { node } = await github.graphql( + ` query($id: ID!) { node(id: $id) { ... on Issue { lastEditedAt editor { login } } @@ -54,7 +60,9 @@ module.exports = async function cleanup({ github, context, core, now = new Date( } } } - `, { id: item.node_id }); + `, + { id: item.node_id }, + ); return { editedAt: node.editor?.login === item.user.login ? node.lastEditedAt : null, @@ -63,14 +71,16 @@ module.exports = async function cleanup({ github, context, core, now = new Date( } async function recordDraftActivity(pullRequest, comments) { - const previous = comments.find(comment => - comment.user?.login === 'github-actions[bot]' && ACTIVITY_MARKER.test(comment.body)); + const previous = comments.find( + (comment) => comment.user?.login === 'github-actions[bot]' && ACTIVITY_MARKER.test(comment.body), + ); const activity = { head: pullRequest.head.sha, at: now.toISOString() }; - const body = `External draft pull requests are closed after seven days without contributor activity. ` - + `Pushes, edits, and author replies count; bot comments do not. ` - + `Maintainers can exempt directed work with the \`${EXEMPT_LABEL}\` label. ` - + `See the [contribution guide](${GUIDE}#pull-request-requirements).\n\n` - + ``; + const body = + `External draft pull requests are closed after seven days without contributor activity. ` + + `Pushes, edits, and author replies count; bot comments do not. ` + + `Maintainers can exempt directed work with the \`${EXEMPT_LABEL}\` label. ` + + `See the [contribution guide](${GUIDE}#pull-request-requirements).\n\n` + + ``; core.info(`Record draft activity for #${pullRequest.number}${dryRun ? ' (dry run)' : ''}`); if (dryRun) return; @@ -82,13 +92,20 @@ module.exports = async function cleanup({ github, context, core, now = new Date( } async function checkDraft(pullRequest, recordActivity = false) { - if (pullRequest.state !== 'open' || !pullRequest.draft || isExempt(pullRequest) - || !pullRequest.user || pullRequest.user.type === 'Bot' - || await isMaintainer(pullRequest.user)) return; + if ( + pullRequest.state !== 'open' || + !pullRequest.draft || + isExempt(pullRequest) || + !pullRequest.user || + pullRequest.user.type === 'Bot' || + (await isMaintainer(pullRequest.user)) + ) + return; const comments = await commentsFor(pullRequest.number); - const notice = comments.find(comment => - comment.user?.login === 'github-actions[bot]' && ACTIVITY_MARKER.test(comment.body)); + const notice = comments.find( + (comment) => comment.user?.login === 'github-actions[bot]' && ACTIVITY_MARKER.test(comment.body), + ); const recorded = notice ? JSON.parse(notice.body.match(ACTIVITY_MARKER)[1]) : null; // Commit dates do not record push times. Remember observed head changes, @@ -99,16 +116,23 @@ module.exports = async function cleanup({ github, context, core, now = new Date( } const edits = await authorEditAndDraftStart(pullRequest); - let lastActivity = Math.max(Date.parse(recorded.at), Date.parse(pullRequest.created_at), + let lastActivity = Math.max( + Date.parse(recorded.at), + Date.parse(pullRequest.created_at), edits.editedAt ? Date.parse(edits.editedAt) : 0, - edits.draftStartedAt ? Date.parse(edits.draftStartedAt) : 0); + edits.draftStartedAt ? Date.parse(edits.draftStartedAt) : 0, + ); if (now.getTime() - lastActivity < 7 * DAY) return; const reviews = await github.paginate(github.rest.pulls.listReviews, { - ...repository, pull_number: pullRequest.number, per_page: 100, + ...repository, + pull_number: pullRequest.number, + per_page: 100, }); const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, { - ...repository, pull_number: pullRequest.number, per_page: 100, + ...repository, + pull_number: pullRequest.number, + per_page: 100, }); for (const response of [...comments, ...reviews, ...reviewComments]) { @@ -120,18 +144,27 @@ module.exports = async function cleanup({ github, context, core, now = new Date( if (now.getTime() - lastActivity < 7 * DAY) return; const { data: current } = await github.rest.pulls.get({ - ...repository, pull_number: pullRequest.number, + ...repository, + pull_number: pullRequest.number, }); - if (current.state !== 'open' || !current.draft || isExempt(current) - || current.head.sha !== pullRequest.head.sha || current.updated_at !== pullRequest.updated_at) return; + if ( + current.state !== 'open' || + !current.draft || + isExempt(current) || + current.head.sha !== pullRequest.head.sha || + current.updated_at !== pullRequest.updated_at + ) + return; core.info(`Close inactive external draft #${current.number}${dryRun ? ' (dry run)' : ''}`); if (dryRun) return; await github.rest.issues.createComment({ - ...repository, issue_number: current.number, - body: `Closing this external draft after seven days without contributor activity. ` - + `Bot comments do not extend the deadline. You can reopen it when you resume the work. ` - + `See the [contribution guide](${GUIDE}#pull-request-requirements).`, + ...repository, + issue_number: current.number, + body: + `Closing this external draft after seven days without contributor activity. ` + + `Bot comments do not extend the deadline. You can reopen it when you resume the work. ` + + `See the [contribution guide](${GUIDE}#pull-request-requirements).`, }); await github.rest.pulls.update({ ...repository, pull_number: current.number, state: 'closed' }); } @@ -139,58 +172,88 @@ module.exports = async function cleanup({ github, context, core, now = new Date( async function checkInformationRequest(issue) { if (issue.pull_request || isExempt(issue) || !issue.user || issue.user.type === 'Bot') return; const events = await github.paginate(github.rest.issues.listEventsForTimeline, { - ...repository, issue_number: issue.number, per_page: 100, + ...repository, + issue_number: issue.number, + per_page: 100, }); - const request = events.filter(event => event.event === 'labeled' && event.label.name === INFO_LABEL) + const request = events + .filter((event) => event.event === 'labeled' && event.label.name === INFO_LABEL) .sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0]; - if (!request || now.getTime() - Date.parse(request.created_at) < 14 * DAY - || !await isMaintainer(request.actor, true)) return; + if ( + !request || + now.getTime() - Date.parse(request.created_at) < 14 * DAY || + !(await isMaintainer(request.actor, true)) + ) + return; const requestedAt = Date.parse(request.created_at); const comments = await commentsFor(issue.number); - const authorResponded = comments.some(comment => comment.user?.login === issue.user.login - && comment.user.type !== 'Bot' - && Math.max(Date.parse(comment.created_at), Date.parse(comment.updated_at)) >= requestedAt); - const authorReopened = events.some(event => event.event === 'reopened' - && event.actor?.login === issue.user.login && Date.parse(event.created_at) >= requestedAt); + const authorResponded = comments.some( + (comment) => + comment.user?.login === issue.user.login && + comment.user.type !== 'Bot' && + Math.max(Date.parse(comment.created_at), Date.parse(comment.updated_at)) >= requestedAt, + ); + const authorReopened = events.some( + (event) => + event.event === 'reopened' && + event.actor?.login === issue.user.login && + Date.parse(event.created_at) >= requestedAt, + ); const edits = await authorEditAndDraftStart(issue); if (authorResponded || authorReopened || (edits.editedAt && Date.parse(edits.editedAt) >= requestedAt)) return; const { data: current } = await github.rest.issues.get({ ...repository, issue_number: issue.number }); - if (current.state !== 'open' || isExempt(current) - || !current.labels.some(label => label.name === INFO_LABEL) || current.updated_at !== issue.updated_at) return; + if ( + current.state !== 'open' || + isExempt(current) || + !current.labels.some((label) => label.name === INFO_LABEL) || + current.updated_at !== issue.updated_at + ) + return; core.info(`Close unanswered information request #${current.number}${dryRun ? ' (dry run)' : ''}`); if (dryRun) return; await github.rest.issues.createComment({ - ...repository, issue_number: current.number, - body: `Closing this issue because the reporter has not responded within 14 days of the maintainer's ` - + `request for information. This does not mean the reported problem is fixed. ` - + `Please provide the requested information here so the issue can be reconsidered. ` - + `See the [contribution guide](${GUIDE}#bug-reports).`, + ...repository, + issue_number: current.number, + body: + `Closing this issue because the reporter has not responded within 14 days of the maintainer's ` + + `request for information. This does not mean the reported problem is fixed. ` + + `Please provide the requested information here so the issue can be reconsidered. ` + + `See the [contribution guide](${GUIDE}#bug-reports).`, }); await github.rest.issues.update({ - ...repository, issue_number: current.number, state: 'closed', state_reason: 'not_planned', + ...repository, + issue_number: current.number, + state: 'closed', + state_reason: 'not_planned', }); } if (context.eventName === 'pull_request_target') { if (context.payload.sender?.type === 'Bot') return; const { data: pullRequest } = await github.rest.pulls.get({ - ...repository, pull_number: context.payload.pull_request.number, + ...repository, + pull_number: context.payload.pull_request.number, }); await checkDraft(pullRequest, true); return; } for await (const { data: pullRequests } of github.paginate.iterator(github.rest.pulls.list, { - ...repository, state: 'open', per_page: 100, + ...repository, + state: 'open', + per_page: 100, })) { for (const pullRequest of pullRequests) await checkDraft(pullRequest); } for await (const { data: issues } of github.paginate.iterator(github.rest.issues.listForRepo, { - ...repository, state: 'open', labels: INFO_LABEL, per_page: 100, + ...repository, + state: 'open', + labels: INFO_LABEL, + per_page: 100, })) { for (const issue of issues) await checkInformationRequest(issue); } diff --git a/.github/scripts/submission-cleanup.test.cjs b/.github/scripts/submission-cleanup.test.cjs index b6f141ad66..6411a52e38 100644 --- a/.github/scripts/submission-cleanup.test.cjs +++ b/.github/scripts/submission-cleanup.test.cjs @@ -11,23 +11,38 @@ const recentDate = '2026-10-19T12:00:00Z'; function draft(overrides = {}) { return { - number: 1, node_id: 'PR_1', state: 'open', draft: true, user: author, - labels: [], head: { sha: 'old-head' }, created_at: oldDate, updated_at: oldDate, + number: 1, + node_id: 'PR_1', + state: 'open', + draft: true, + user: author, + labels: [], + head: { sha: 'old-head' }, + created_at: oldDate, + updated_at: oldDate, ...overrides, }; } function issue(overrides = {}) { return { - number: 2, node_id: 'I_2', state: 'open', user: author, - labels: [{ name: 'not enough info' }], created_at: oldDate, updated_at: oldDate, + number: 2, + node_id: 'I_2', + state: 'open', + user: author, + labels: [{ name: 'not enough info' }], + created_at: oldDate, + updated_at: oldDate, ...overrides, }; } function notice(at = oldDate, head = 'old-head') { return { - id: 10, user: bot, created_at: oldDate, updated_at: oldDate, + id: 10, + user: bot, + created_at: oldDate, + updated_at: oldDate, body: ``, }; } @@ -47,27 +62,38 @@ async function run(options = {}) { const pullRequests = options.pullRequests || []; const issues = options.issues || []; const methods = [ - 'repos.getCollaboratorPermissionLevel', 'pulls.list', 'pulls.get', 'pulls.listReviews', - 'pulls.listReviewComments', 'pulls.update', 'issues.listForRepo', 'issues.get', - 'issues.listComments', 'issues.listEventsForTimeline', 'issues.createComment', - 'issues.updateComment', 'issues.update', + 'repos.getCollaboratorPermissionLevel', + 'pulls.list', + 'pulls.get', + 'pulls.listReviews', + 'pulls.listReviewComments', + 'pulls.update', + 'issues.listForRepo', + 'issues.get', + 'issues.listComments', + 'issues.listEventsForTimeline', + 'issues.createComment', + 'issues.updateComment', + 'issues.update', ]; const rest = { repos: {}, pulls: {}, issues: {} }; for (const method of methods) { const [resource, name] = method.split('.'); - rest[resource][name] = async parameters => { + rest[resource][name] = async (parameters) => { calls.push({ method, ...parameters }); if (method === 'repos.getCollaboratorPermissionLevel') { - return { data: options.permissions?.[parameters.username] || { - permission: parameters.username === 'maintainer' ? 'write' : 'read', - } }; + return { + data: options.permissions?.[parameters.username] || { + permission: parameters.username === 'maintainer' ? 'write' : 'read', + }, + }; } if (method === 'pulls.get') { - const current = pullRequests.find(item => item.number === parameters.pull_number); + const current = pullRequests.find((item) => item.number === parameters.pull_number); return { data: { ...current, ...options.currentPullRequest } }; } if (method === 'issues.get') { - const current = issues.find(item => item.number === parameters.issue_number); + const current = issues.find((item) => item.number === parameters.issue_number); return { data: { ...current, ...options.currentIssue } }; } mutations.push({ method, ...parameters }); @@ -91,7 +117,8 @@ async function run(options = {}) { } }; const github = { - rest, paginate, + rest, + paginate, graphql: async (query, variables) => ({ node: options.edits?.[variables.id] || { lastEditedAt: null, editor: null }, }), @@ -100,16 +127,22 @@ async function run(options = {}) { github, context: { repo: { owner: 'hypervel', repo: 'components' }, - eventName: options.eventName || 'schedule', payload: options.payload || {}, + eventName: options.eventName || 'schedule', + payload: options.payload || {}, }, - core: { info: message => logs.push(message) }, now, dryRun: options.dryRun || false, + core: { info: (message) => logs.push(message) }, + now, + dryRun: options.dryRun || false, }); return { mutations, logs, calls }; } test('closes an inactive external draft with an explanation and no branch deletion', async () => { const { mutations } = await run({ pullRequests: [draft()], comments: { 1: [notice()] } }); - assert.deepEqual(mutations.map(change => change.method), ['issues.createComment', 'pulls.update']); + assert.deepEqual( + mutations.map((change) => change.method), + ['issues.createComment', 'pulls.update'], + ); assert.match(mutations[0].body, /seven days/); assert.match(mutations[0].body, /contributions\.md/); assert.equal(mutations[1].state, 'closed'); @@ -124,7 +157,8 @@ test('gives existing drafts a full seven days from their first notice', async () test('records a newly observed head even if the commit has an old date', async () => { const { mutations } = await run({ - pullRequests: [draft({ head: { sha: 'new-head' } })], comments: { 1: [notice()] }, + pullRequests: [draft({ head: { sha: 'new-head' } })], + comments: { 1: [notice()] }, }); assert.equal(mutations.length, 1); assert.equal(mutations[0].method, 'issues.updateComment'); @@ -140,18 +174,23 @@ test('ignores contributor-supplied tracking markers', async () => { test('honors the seven-day boundary', async () => { const before = await run({ - pullRequests: [draft()], comments: { 1: [notice('2026-10-13T12:00:01Z')] }, + pullRequests: [draft()], + comments: { 1: [notice('2026-10-13T12:00:01Z')] }, }); assert.equal(before.mutations.length, 0); const atDeadline = await run({ - pullRequests: [draft()], comments: { 1: [notice('2026-10-13T12:00:00Z')] }, + pullRequests: [draft()], + comments: { 1: [notice('2026-10-13T12:00:00Z')] }, }); assert.equal(atDeadline.mutations.at(-1).method, 'pulls.update'); }); test('exempts ready, closed, maintainer-authored, bot-authored, and labeled pull requests', async () => { for (const overrides of [ - { draft: false }, { state: 'closed' }, { user: maintainer }, { user: bot }, + { draft: false }, + { state: 'closed' }, + { user: maintainer }, + { user: bot }, { labels: [{ name: 'maintainer-directed' }] }, ]) { const { mutations } = await run({ pullRequests: [draft(overrides)] }); @@ -167,7 +206,9 @@ test('author comments, comment edits, reviews, and review replies extend the dea { reviewComments: [response()] }, ]) { const { mutations } = await run({ - pullRequests: [draft()], comments: { 1: [notice()] }, ...additions, + pullRequests: [draft()], + comments: { 1: [notice()] }, + ...additions, }); assert.equal(mutations.length, 0); } @@ -175,7 +216,8 @@ test('author comments, comment edits, reviews, and review replies extend the dea test('bot comments and maintainer reviews do not reset the draft timer', async () => { const { mutations } = await run({ - pullRequests: [draft()], comments: { 1: [notice(), response(bot)] }, + pullRequests: [draft()], + comments: { 1: [notice(), response(bot)] }, reviews: [{ user: maintainer, submitted_at: recentDate }], reviewComments: [response(bot)], }); @@ -188,7 +230,9 @@ test('author body edits and recent draft conversion or reopening extend the dead { timelineItems: { nodes: [{ createdAt: recentDate }] } }, ]) { const { mutations } = await run({ - pullRequests: [draft()], comments: { 1: [notice()] }, edits: { PR_1: edit }, + pullRequests: [draft()], + comments: { 1: [notice()] }, + edits: { PR_1: edit }, }); assert.equal(mutations.length, 0); } @@ -196,11 +240,16 @@ test('author body edits and recent draft conversion or reopening extend the dead test('does not close a draft changed while its activity was being checked', async () => { for (const currentPullRequest of [ - { draft: false }, { state: 'closed' }, { head: { sha: 'new-head' } }, - { updated_at: recentDate }, { labels: [{ name: 'maintainer-directed' }] }, + { draft: false }, + { state: 'closed' }, + { head: { sha: 'new-head' } }, + { updated_at: recentDate }, + { labels: [{ name: 'maintainer-directed' }] }, ]) { const { mutations } = await run({ - pullRequests: [draft()], comments: { 1: [notice()] }, currentPullRequest, + pullRequests: [draft()], + comments: { 1: [notice()] }, + currentPullRequest, }); assert.equal(mutations.length, 0); } @@ -208,7 +257,9 @@ test('does not close a draft changed while its activity was being checked', asyn test('human pull request events record activity without closing anything', async () => { const { mutations } = await run({ - pullRequests: [draft()], comments: { 1: [notice()] }, eventName: 'pull_request_target', + pullRequests: [draft()], + comments: { 1: [notice()] }, + eventName: 'pull_request_target', payload: { pull_request: { number: 1 }, sender: author }, }); assert.equal(mutations.length, 1); @@ -217,7 +268,8 @@ test('human pull request events record activity without closing anything', async test('bot-triggered edits do not update the draft activity notice', async () => { const { mutations } = await run({ - pullRequests: [draft()], eventName: 'pull_request_target', + pullRequests: [draft()], + eventName: 'pull_request_target', payload: { pull_request: { number: 1 }, sender: bot }, }); assert.equal(mutations.length, 0); @@ -225,7 +277,10 @@ test('bot-triggered edits do not update the draft activity notice', async () => test('closes an unanswered maintainer information request as not planned, not fixed', async () => { const { mutations } = await run({ issues: [issue()], events: [request()] }); - assert.deepEqual(mutations.map(change => change.method), ['issues.createComment', 'issues.update']); + assert.deepEqual( + mutations.map((change) => change.method), + ['issues.createComment', 'issues.update'], + ); assert.match(mutations[0].body, /does not mean the reported problem is fixed/); assert.equal(mutations[1].state_reason, 'not_planned'); }); @@ -240,7 +295,8 @@ test('requires a maintainer information request and honors its fourteen-day boun test('accepts information requests from repository triagers', async () => { const { mutations } = await run({ - issues: [issue()], events: [request(oldDate, { login: 'triager', type: 'User' })], + issues: [issue()], + events: [request(oldDate, { login: 'triager', type: 'User' })], permissions: { triager: { permission: 'read', role_name: 'triage' } }, }); assert.equal(mutations.at(-1).method, 'issues.update'); @@ -248,7 +304,9 @@ test('accepts information requests from repository triagers', async () => { test('a reporter response stops closure even if the maintainer has not replied for fourteen days', async () => { const { mutations } = await run({ - issues: [issue()], events: [request()], comments: { 2: [response(author, '2026-10-02T12:00:00Z')] }, + issues: [issue()], + events: [request()], + comments: { 2: [response(author, '2026-10-02T12:00:00Z')] }, }); assert.equal(mutations.length, 0); }); @@ -265,7 +323,9 @@ test('reporter body edits, comment edits, and reopenings count as responses', as test('bot and other-user replies are not reporter responses', async () => { const { mutations } = await run({ - issues: [issue()], events: [request()], comments: { 2: [response(bot), response(maintainer)] }, + issues: [issue()], + events: [request()], + comments: { 2: [response(bot), response(maintainer)] }, edits: { I_2: { editor: { login: bot.login }, lastEditedAt: recentDate } }, }); assert.equal(mutations.at(-1).method, 'issues.update'); @@ -273,7 +333,8 @@ test('bot and other-user replies are not reporter responses', async () => { test('reapplying the information label starts a new request', async () => { const { mutations } = await run({ - issues: [issue()], events: [request(), request(recentDate)], + issues: [issue()], + events: [request(), request(recentDate)], }); assert.equal(mutations.length, 0); }); @@ -281,7 +342,8 @@ test('reapplying the information label starts a new request', async () => { test('exempts directed issues and never treats a pull request as an information-request issue', async () => { for (const overrides of [ { labels: [{ name: 'not enough info' }, { name: 'maintainer-directed' }] }, - { pull_request: {} }, { user: bot }, + { pull_request: {} }, + { user: bot }, ]) { assert.equal((await run({ issues: [issue(overrides)], events: [request()] })).mutations.length, 0); } @@ -289,7 +351,9 @@ test('exempts directed issues and never treats a pull request as an information- test('rechecks issue state, exemption, label, and activity before closing', async () => { for (const currentIssue of [ - { state: 'closed' }, { labels: [] }, { updated_at: recentDate }, + { state: 'closed' }, + { labels: [] }, + { updated_at: recentDate }, { labels: [{ name: 'not enough info' }, { name: 'maintainer-directed' }] }, ]) { assert.equal((await run({ issues: [issue()], events: [request()], currentIssue })).mutations.length, 0); @@ -298,17 +362,21 @@ test('rechecks issue state, exemption, label, and activity before closing', asyn test('processes subsequent API pages', async () => { const { mutations } = await run({ - pullRequests: [draft({ draft: false }), draft({ number: 3 })], comments: { 3: [notice()] }, + pullRequests: [draft({ draft: false }), draft({ number: 3 })], + comments: { 3: [notice()] }, }); assert.equal(mutations.at(-1).pull_number, 3); }); test('dry runs make no writes for either closures or initial notices', async () => { const { mutations, logs } = await run({ - pullRequests: [draft(), draft({ number: 3 })], comments: { 1: [notice()] }, - issues: [issue()], events: [request()], dryRun: true, + pullRequests: [draft(), draft({ number: 3 })], + comments: { 1: [notice()] }, + issues: [issue()], + events: [request()], + dryRun: true, }); assert.equal(mutations.length, 0); assert.equal(logs.length, 3); - assert.ok(logs.every(message => message.endsWith('(dry run)'))); + assert.ok(logs.every((message) => message.endsWith('(dry run)'))); }); diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 440efb6b59..b704c0f881 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -75,7 +75,7 @@ jobs: working-directory: dogfood/ai run: vendor/bin/testbench package:test --no-progress - wayfinder_tests: + javascript_tests: runs-on: ubuntu-latest timeout-minutes: 20 if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" @@ -90,49 +90,47 @@ jobs: with: persist-credentials: false + - name: Trust checkout directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Log trigger context uses: ./.github/actions/log-trigger-context - name: Install Composer dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + - name: Setup Vite+ and install dependencies + uses: voidzero-dev/setup-vp@cab1ab9eaff52d4857875f4f2c016e36af8f2c76 # v1.22.0 with: - node-version: 24 - - - name: Install pnpm - run: | - corepack enable - corepack prepare pnpm@12.10.1 --activate + node-version-file: package.json + cache: true + run-install: | + args: ['--frozen-lockfile'] - - name: Install Node dependencies - run: pnpm install --frozen-lockfile + - name: Check frontend source + run: vp run check - - name: Run MCP browser SDK tests - run: pnpm test:mcp + - name: Build frontend assets + run: vp run build - - name: Run Wayfinder tests - run: pnpm test:wayfinder + - name: Check committed frontend assets + run: | + asset_changes=$(git status --porcelain --untracked-files=all -- \ + src/horizon/dist src/telescope/dist \ + src/foundation/resources/exceptions/renderer/dist \ + src/workbench/public/build src/mcp/resources/js/mcp-sdk.min.js) + if [ -n "$asset_changes" ]; then + printf '%s\n' "$asset_changes" + echo 'Compiled assets are out of date. Run pnpm build from the repository root.' + echo 'See the Compiled Assets section in src/docs/contributions.md. A maintainer must commit rebuilt assets before merging.' + exit 1 + fi + + - name: Run JavaScript tests + run: vp test - name: Run Wayfinder tests against cached routes - run: pnpm test:wayfinder:cached + run: vp run test:wayfinder:cached - name: Type-check generated Wayfinder output - run: pnpm typecheck:wayfinder - - # Telescope's dashboard is a standalone npm package whose devEngines require npm 11.10 or newer. - - name: Install npm for Telescope - run: npm install --global npm@11.21.0 - - - name: Install Telescope dependencies - working-directory: src/telescope - run: npm ci - - - name: Run Telescope dashboard tests - working-directory: src/telescope - run: npm test - - - name: Build Telescope dashboard assets - working-directory: src/telescope - run: npm run build + run: vp run typecheck:wayfinder diff --git a/.gitignore b/.gitignore index 69ce1baa6d..b5fba8f9ce 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,9 @@ /dogfood/testbench-package/.phpunit.cache/ /dogfood/testbench-package/composer.lock /dogfood/testbench-package/vendor/ -/node_modules -/src/*/node_modules +node_modules/ +.vite/ +.vite-plus/ /src/wayfinder/tests/.generated /coverage *.tsbuildinfo diff --git a/.npmrc b/.npmrc deleted file mode 100644 index abe47e0175..0000000000 --- a/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -# Supply-chain defense: require packages to be at least 7 days old. -# Compromised packages typically get yanked within hours; this buffer -# dramatically reduces the chance of pulling a malicious release. -# npm measures this value in days. -min-release-age=7 diff --git a/AGENTS.md b/AGENTS.md index fb6361ee58..b432595329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -785,6 +785,7 @@ When porting Laravel packages, whether first-party or third-party, keep them as - Converting mutable Laravel date construction to Hypervel's immutable date conventions, typing configurable factory output as `CarbonInterface`, and capturing date-modifier return values - Converting container array access (`$app['events']`) and dynamic service-property access (`$app->events`) in ported code to named container methods, and applying the Configuration rules to ported config and its consumers - Adding Laravel-style title docblocks to methods (not classes — see Development Conventions) +- Formatting JavaScript, TypeScript, Vue and frontend CSS sources with the repository's Vite+ formatter configuration, preserving upstream logic, naming, structure and comments - For ported Laravel packages: making them coroutine-safe, adding Swoole performance enhancements (e.g., static property caching), making them pass PHPStan - Not porting upstream framework-specific integrations that only make sense in the source framework (for example packages, drivers) unless Hypervel intentionally has an equivalent surface - Not porting upstream mechanisms that do not make sense in Hypervel's stateful Swoole architecture (for example Laravel's deferred service provider machinery, where the upstream optimization only matters in a per-request bootstrap model) diff --git a/composer.json b/composer.json index f6f3956e7e..1c8acdc95e 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,7 @@ "psy/psysh": "dev-main", "sentry/sentry": "^4.34", "spomky-labs/otphp": "^11.0", - "symfony/console": "^8.1.2", + "symfony/console": "^8.1.10", "symfony/dom-crawler": "^8.1", "symfony/error-handler": "^8.1", "symfony/event-dispatcher": "^8.1", diff --git a/docs/plans/2026-10-27-telescope-vue3-and-vite-plus.md b/docs/plans/2026-10-27-telescope-vue3-and-vite-plus.md index e43f62e0a1..232279303f 100644 --- a/docs/plans/2026-10-27-telescope-vue3-and-vite-plus.md +++ b/docs/plans/2026-10-27-telescope-vue3-and-vite-plus.md @@ -4,25 +4,21 @@ Modernize Telescope's dashboard using Horizon's actual Vue 3 conventions, then consolidate framework JavaScript development under Vite+, pnpm workspaces, and dependency catalogs. Deliver two independently reviewable PRs against `0.4`, in that order. Do not change `0.3` or `main`. -Work in `/home/binaryfire/workspace/contrib/hypervel/components-vite-plus`. The initial branch is `chore/telescope-vue3`. After PR 1 merges, create PR 2's branch from updated `origin/0.4` in this worktree; do not mix the toolchain migration into PR 1. Commit whole files in focused commits with explanatory bodies. Commit, push, open, or merge PRs only when authorized; never squash by default. +Part 1 was merged and its worktree removed. For Part 2, create a fresh `/home/binaryfire/workspace/contrib/hypervel/components-vite-plus` worktree and branch from updated `origin/0.4`, carrying forward this plan's latest changes. Commit whole files in focused commits with explanatory bodies. Commit, push, open, or merge PRs only when authorized; never squash by default. -Read this plan, the monorepo root `CLAUDE.md`, and the worktree's `AGENTS.md` after compaction. This plan does not override their approval, verification, source-reading, or dependency-management requirements. No implementation is authorized merely by writing the plan. +Use one implementation checkpoint for the complete Part 2 monorepo refactor. Run focused checks during development, then complete verification and a full self-review before one peer code-review loop through signoff. Do not split packages or tooling steps into separate checkpoints. This does not change the requirement to investigate non-trivial unexpected issues and reach peer consensus before implementing their fixes. -Part 1 implementation and verification are complete. Remaining work: +Read this plan, the monorepo root `CLAUDE.md`, and the worktree's `AGENTS.md` after compaction. This plan does not override their approval, verification, source-reading, or dependency-management requirements. No implementation is authorized merely by writing the plan. -- After Part 1 merges, check the retired lockfile alerts and coordinate Telescope's upstream-sync notes as described below. -- Resolve the formatting approval item before its dependent Part 2 changes. -- After Part 1 merges, implement and verify Part 2. +Part 1 is merged on both remotes, Telescope's upstream-sync notes are updated, and GitHub closed the retired archive's lockfile alerts. The formatting exception below is approved. Part 2 is published on both remotes. Remaining work: complete the authorized PR review and CI follow-up, merge when green, then perform the post-merge dependency-graph and upstream-sync checks below. ## Maintenance tradeoff and approval items Both changes address real maintenance costs. Vue 2 constrains Telescope's Vite plugin and retains an unpatched compiler advisory. Retaining Options API, Horizon's patterns, and Telescope's recognizable structure makes future template/lifecycle adaptations straightforward. Request cancellation, routing, and modal behavior still require understanding and tests; upstream patches will not always apply unchanged. A shared toolchain then reduces duplicated dependency decisions, vulnerable lockfile drift, and inconsistent commands across the framework. Use standard Vite+/pnpm behavior first; depart only for a demonstrated requirement. -The following proposal requires owner approval; writing it here is not approval: +The owner has approved the following exception: -- **Formatting exception:** permit deterministic Vite+ formatting of maintained first-party frontend source/tests, including ports. Add this approved adaptation to `AGENTS.md`'s Porting Policy during Part 2: “Formatting JavaScript, TypeScript, Vue and frontend CSS sources with the repository's Vite+ formatter configuration, preserving upstream logic, naming, structure and comments.” Use standard root formatter overrides based on upstream conventions; vendored/generated files remain excluded. This gives maintained source normal Vite+ quality coverage without changing its architecture. Owner approval is required before applying the formatter or editing the rule. - -The owner has approved deleting the entire `_archive/docs/0.3` directory. Do this in a separate commit in Part 1, checking remaining documentation/archive references for stale links and preserving other archives. Confirm its lockfile alerts close after GitHub refreshes the graph. This retires inactive documentation and its dependency metadata; it does not fix vulnerable code in a supported application. +- **Formatting exception — approved:** permit deterministic Vite+ formatting of maintained first-party frontend source/tests, including ports. Add this approved adaptation to `AGENTS.md`'s Porting Policy during Part 2: “Formatting JavaScript, TypeScript, Vue and frontend CSS sources with the repository's Vite+ formatter configuration, preserving upstream logic, naming, structure and comments.” Use standard root formatter overrides based on upstream conventions; vendored/generated files remain excluded. This approval covers applying the formatter and updating the corresponding Porting Policy rule. ## Shared decisions @@ -42,7 +38,7 @@ The paths below are relative to this worktree unless absolute. Read the actual s | Area | Relevant files and constraints | | --- | --- | | Horizon reference | `src/horizon/resources/js/app.js`, `base.js`, `components/Alert.vue`, `components/Poll.vue`, and `screens/recentJobs/index.vue`: Vue 3, Router 4, Options API, native Bootstrap 5 APIs. Its style is a reference, not proof that every existing detail is correct. | -| Telescope | `src/telescope/resources/js/`, `resources/views/layout.blade.php`, `resources/sass/`, `vite.config.js`, `vitest.config.js`, `package.json`, `package-lock.json`: Vue 2.7, Router 3, Bootstrap 4, jQuery, Popper 1, Vue Test Utils 1. | +| Telescope | `src/telescope/resources/js/`, `resources/views/layout.blade.php`, `resources/sass/`, `vite.config.js`, `vitest.config.js`, `package.json`, `package-lock.json`: Vue 3, Router 4, Bootstrap 5.3 and Vue Test Utils 2; independent npm installation and separate build/test configurations. | | Upstream | Read-only Laravel references under `/home/binaryfire/workspace/references/laravel/{telescope,horizon}`. Laravel Telescope currently remains on Vue 2. Compare source/tests and the established upstream tracking records; adapting a dashboard to Vue 3 does not change which upstream fixes need porting. | | Root tooling | `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `vitest.config.ts`, `tsconfig.json`: pnpm already exists; Horizon, Telescope, and Workbench are excluded. The nested exception renderer and AI workbench are not selected by `src/*`. | | Existing CI | `.github/workflows/tests.yml` runs root MCP/Wayfinder tests and a separate npm installation/test for Telescope. `.github/workflows/skeletons.yml` builds a fresh base application separately. | @@ -58,125 +54,13 @@ Primary research: - [Oxc compatibility](https://oxc.rs/compatibility.html): Vue formatting and script linting do not imply Vue template linting. Vue compilation and behavioral tests remain necessary. - [Dependabot catalogs](https://github.blog/changelog/2025-02-04-dependabot-now-supports-pnpm-workspace-catalogs-ga/), [supported ecosystems](https://docs.github.com/en/code-security/reference/supply-chain-security/supported-ecosystems-and-repositories), and [dependency graph limitations](https://docs.github.com/en/code-security/reference/supply-chain-security/dependency-graph-supported-package-ecosystems): version-update support and security-graph ingestion are separate capabilities. Do not assume a green Dependabot configuration proves transitive advisory coverage. -## Part 1 — Telescope dashboard migration - -### Buffered-response transport prerequisite - -Remove `Constant::OPTION_BUFFER_OUTPUT_SIZE => 2 * 1024 * 1024` from `src/foundation/config/server.php`. Review this fix together with the complete dashboard migration, then give it a separate commit. This inherited override makes Swoole's process-mode send reject bodies over 2 MiB after HTTP/1.1 headers have been sent, producing empty responses. It affects buffered HTML/JSON and large individual streamed writes. Swoole's native default is `UINT_MAX`; the setting checks send size, not buffer allocation. Use that native default instead of substituting another arbitrary cap. Leave `OPTION_SOCKET_BUFFER_SIZE` unchanged. - -Regenerate both skeleton configs with `composer sync:skeleton-config`, remove the same override from `tests/Benchmarks/Sentry/Fixtures/app/config/server.php`, and run `composer check:skeleton-config`. Check for other maintained copies before committing. Do not edit generated skeleton files directly or change historical archives. No porting-guide or upgrade entry is needed: this removes an internal transport restriction in unreleased 0.4, whose upgrade guidance already requires a fresh application. - -Leave `ResponseBridge` and the dependency unchanged. Native `end()` preserves compression, `Content-Length`, and existing protocol/trailer handling. A PHP `write()` loop would disable Swoole compression and add unnecessary framing and failure paths. Removing the size override does not make buffered responses memory-bounded; streaming and file responses remain the appropriate mechanisms for large generated output. Do not add a compression subsystem, transport abstraction, or new application option. - -Add one focused real process-mode loopback regression using the shipped server settings and `ResponseBridge`: a buffered HTTP/1.1 body over 2 MiB must arrive intact with the correct `Content-Length`. The fixture must consume the production settings so restoring the override makes the test fail; override only fixture isolation settings such as worker count, logging and ports. Follow `ResponseCancellationTest`'s automatic-port, bounded-wait and unconditional-cleanup pattern. Do not add a mock matrix or duplicate compression, trailer and failure-path tests for unchanged bridge code. Direct loopback checks have already verified native compression and a single streamed write over 2 MiB without the override. - -Run the new test file immediately after writing it. At completion run `composer lint:fix`, `composer analyse`, then `composer test:parallel -- --processes=4 tests/HttpServer`, along with the config synchronization check. No separate WebSocket/Reverb/gRPC test expansion is needed for an unchanged bridge. - -### 1. Establish the behavior and build baseline - -Read all Telescope frontend components, screens, mixins, Blade layout, styles, and existing query-preview tests. Compare Horizon's corresponding setup and frontend conventions before editing each area. Use current Laravel Telescope as the behavior reference, including fixes already present in Hypervel. - -Run the current Telescope tests and production build using its existing independent npm setup. Record expected asset filenames and raw/compressed sizes outside the repository. Exercise the existing UI sufficiently to distinguish migration failures from inherited defects. This PR covers Telescope, the server-config correction above, directly required CI/docs updates, and the authorized archive removal. The corresponding Horizon modal correction belongs with its Bootstrap upgrade and shared test setup in Part 2, avoiding an interim independent npm test setup. - -Preserve these contracts: - -- `/telescope` and configured empty, root, or nested Telescope paths; route names, IDs, query strings, history navigation, and deep links. -- Existing API endpoints, request bodies, CSRF behavior, timezone formatting, recording toggles, clear confirmation, and auto-load local-storage preference. -- Lists, filtering, family grouping, pagination, pending-job updates, related entries, dumps, monitored tags, SQL formatting/highlighting, JSON inspection, and clipboard contents. -- Light/dark styling, responsive layout, modal/dropdown behavior, and supported HTTP development installations. -- `Telescope::css()`, `js()`, script variables, nonce handling, and the Blade shell. The dashboard assets are inlined; a build that succeeds but emits unresolved external chunks is not acceptable. - -### 2. Move onto Vue 3 and the Horizon application pattern - -Use package-manager commands to update `src/telescope/package.json` and its npm lockfile. Install supported compatible releases of Vue 3, Router 4, `@vitejs/plugin-vue`, Vue Test Utils 2, and `vue-json-pretty` 2. Replace `@vitejs/plugin-vue2`, Vue 2 compiler dependencies, and the Vue 2 clipboard wrapper. Declare directly imported dependencies; do not rely on npm hoisting to conceal missing declarations. - -Upgrade Telescope's standalone Vite/Vitest to supported versions compatible with these plugins and Node 24. PR 1 still uses plain Vite and npm; Vite+ and workspace/catalog changes belong to Part 2. Preserve the minimum-release-age policy and inspect changes in the complete dependency graph. Do not use `--force`, legacy-peer-deps, or a compatibility shim to pass installation. - -Follow `src/horizon/resources/js/app.js`: - -```js -import { createApp } from 'vue/dist/vue.esm-bundler.js'; -import { createRouter, createWebHistory } from 'vue-router'; - -const app = createApp({ - data() { - return { - // Telescope's existing root-owned state. - }; - }, -}); - -const router = createRouter({ - history: createWebHistory(routerBasePath), - routes: Routes, -}); - -app.use(router); -app.component('preview-screen', PreviewScreen); -app.mixin(Base); -app.mount('#telescope'); -``` - -This is a structural example, not replacement code: retain all state, registrations, listeners, and bootstrap settings. The compiler-enabled Vue import is deliberate because the root template lives in Blade. Do not copy Horizon's product-specific globals or proxy-path configuration into Telescope. Nest each list/detail route pair under a component-less section parent, with `''` and `':id'` children, as Horizon does. Router 4 matches active links by route record, so flat sibling records lose the sidebar highlight on detail pages. Preserve existing names and URLs, and verify active links with the real route definitions. Check initial navigation and route reuse rather than adding speculative waits or forcing remounts. - -Use `defineConfig` and the Vue 3 plugin in Telescope's build and test configs. Rely on the plugin's Options API and production feature-flag defaults; add no redundant `define` settings. For Vite 8 use `build.rolldownOptions` rather than deprecated `rollupOptions`. Retain current output locations and the inline-compatible runtime format; check Lightning CSS output in the visual comparison. No `@vue/compat`, dual Vue runtime, runtime version detection, or Vue 2 translation layer. - -### 3. Convert templates, ownership, and lifecycle behavior - -Keep existing screen/file names and route organization. Match Horizon's component import/registration style and readable Options API method layout. Remove redundant `type="text/ecmascript-6"` script attributes, unused imports/components/data, empty option blocks, and empty styles when no longer needed. Keep useful behavior comments and the intentional upstream exclusion notes. - -- Replace `slot`/`slot-scope` with `#name`/`v-slot`. Convert named slots on real elements into `