From 5e2555558feb720907cbac58f479017347238cfe Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Tue, 11 Aug 2026 22:10:20 -0500 Subject: [PATCH] chore(ci): update skills - fix missing skill details - align pre commit hooks to pnpm scripts - fix agent cloud env var - add project authoring skill for standing up new projects Signed-off-by: Cory Rylan --- .agents/hooks/pre-tool-use-bash.sh | 2 +- .agents/skills/agent-upkeep/SKILL.md | 28 +- .../skills/authoring-documentation/SKILL.md | 2 +- .agents/skills/authoring-projects/SKILL.md | 60 +++ .../references/integration-checklist.md | 50 +++ .../scripts/scaffold-project.mjs | 417 ++++++++++++++++++ .agents/skills/guidance-build-system/SKILL.md | 8 +- .husky/pre-commit | 6 +- NOTICE.md | 12 +- package.json | 2 +- projects/cli/NOTICE.md | 12 +- projects/internals/BUILD.md | 9 +- 12 files changed, 578 insertions(+), 30 deletions(-) create mode 100644 .agents/skills/authoring-projects/SKILL.md create mode 100644 .agents/skills/authoring-projects/references/integration-checklist.md create mode 100644 .agents/skills/authoring-projects/scripts/scaffold-project.mjs diff --git a/.agents/hooks/pre-tool-use-bash.sh b/.agents/hooks/pre-tool-use-bash.sh index 35d9db8b0..8c2d701a5 100755 --- a/.agents/hooks/pre-tool-use-bash.sh +++ b/.agents/hooks/pre-tool-use-bash.sh @@ -8,7 +8,7 @@ COMMAND=$(hook_command_from_input "$INPUT" || true) GIT_PREFIX='(^|[;&|][[:space:]]*)git([[:space:]]+(-C[[:space:]]+[^[:space:];&|]+|--no-pager|-c[[:space:]]+[^[:space:];&|]+|--work-tree(=|[[:space:]]+)[^[:space:];&|]+))*[[:space:]]+' HOOK_OS=$(uname -s 2>/dev/null || true) -if [[ -z "${NVE_AGENT:-}" && "$HOOK_OS" != "Darwin" ]]; then +if [[ "$HOOK_OS" != "Darwin" && ( -z "${NVE_AGENT:-}" || "${NVE_AGENT:-}" == "cursor" ) ]]; then export NVE_AGENT="isolated" fi diff --git a/.agents/skills/agent-upkeep/SKILL.md b/.agents/skills/agent-upkeep/SKILL.md index da375ea8f..c047555bb 100644 --- a/.agents/skills/agent-upkeep/SKILL.md +++ b/.agents/skills/agent-upkeep/SKILL.md @@ -138,7 +138,7 @@ There are suppressions left that do not occur anymore. Consider re-running the c This is success, not failure. It means your fix worked and the stale entry is still on disk. Resolve it by pruning. Do **not** revert your fix, do not re-run `--suppress-rule` to make the message go away, and do not add `--pass-on-unpruned-suppressions`, which only hides the condition and leaves the ratchet slipping backwards. -**Done when**: the rule's suppression count strictly decreases, no stale suppressions remain, the file's public API is byte-identical, `pnpm run lint` passes with no new inline disables, and `pnpm run test` and `pnpm run test:types` pass. +**Done when**: the rule's suppression count strictly decreases, no stale suppressions remain, the file's public API is byte-identical, and every applicable project verification script passes with no new inline disables. @@ -159,7 +159,7 @@ Do not invent bugs. If the script finds no quarantined test, it moves to another ### Procedure 1. **Confirm the failure first.** Un-skip the test and run it. It must fail. -2. If it passes immediately, the bug is already fixed. That is still a valid pull request: remove the `.skip` and say so. Stop there. +2. If it passes immediately, the bug is already fixed. That is still a valid pull request: remove the `.skip`, say so, and stop editing. Because this changes only test maintenance, use a `chore` commit and do not trigger an empty package release. 3. If you cannot make it fail, the quarantine is not reproducible. Re-apply the skip, stop, and report what you tried. 4. Otherwise make the smallest change that turns the test green. 5. Do not refactor surrounding code, do not fix adjacent issues, do not tidy imports. @@ -167,24 +167,28 @@ Do not invent bugs. If the script finds no quarantined test, it moves to another The script ranks quarantined **visual** tests last because you cannot update visual baselines. If the script selects one and the fix needs a new baseline, stop, and report it for a human. -**Done when**: the test fails before the fix and passes after, you do not change its assertions, you change no other test, and the full project CI passes. +**Done when**: the test either fails before the production fix and passes after, or passes immediately and needs only removal of `.skip`. In both cases, do not change its assertions or any other test, and require the full project CI to pass. ## Verification -After the final change, run lint first. For Mode A, run it from every selector-provided `packages[].workingDirectory`; for other tasks, run it from the target project directory: +After the final change, inspect the target project's `package.json` and run lint first when the script exists. For Mode A, run it from every selector-provided `packages[].workingDirectory`; for other tasks, run it from the target project directory: ```shell -mise exec -- pnpm run lint +mise exec -- pnpm run --if-present lint ``` -Then run tests and type checks from the project containing the hand-fixed source file for Mode A, or from the target project directory for other tasks: +Then run every command below from the project containing the hand-fixed source file for Mode A, or from the target project directory for other tasks. `--if-present` skips only scripts that the project does not define: ```shell -mise exec -- pnpm run test -mise exec -- pnpm run test:types +mise exec -- pnpm run --if-present test +mise exec -- pnpm run --if-present test:types +mise exec -- pnpm run --if-present test:axe +mise exec -- pnpm run --if-present test:ssr +mise exec -- pnpm run --if-present test:lighthouse +mise exec -- pnpm run --if-present test:visual ``` -For component changes also run `mise exec -- pnpm run test:axe` and `mise exec -- pnpm run test:ssr`. Do not update visual baselines. If a visual test fails, your change changed behavior: stop. +Record absent scripts as not available rather than as failures. Do not update visual baselines. If a visual test fails, your change changed behavior: stop. Then self-review with the [audit-code skill](/.agents/skills/audit-code/SKILL.md) and fix anything it flags. @@ -217,7 +221,7 @@ Always use the `upkeep/` prefix. The next run lists unmerged `upkeep/*` branches Commit messages must follow `commitlint.config.js`: -- type is `fix` for the bug task, `chore` for everything else +- type is `fix` when the bug task changes production behavior; use `chore` for every other task, including an already-fixed bug where the only change removes `.skip` - include a scope. Use the project directory name under `projects/`, except use `internals` for anything under `projects/internals/` and `docs` for `projects/site`. `commitlint.config.js` holds the authoritative list; if your target does not map to one of its values, use `internals`. - subject is lower case, no trailing period, 100 characters max - include a `Signed-off-by:` trailer @@ -233,13 +237,15 @@ raising line coverage from 84% to 96%. No behavior change. Signed-off-by: Elements Upkeep Agent ``` -Open the pull request as a **draft**, labeled `upkeep`, with a body that states: +Open the pull request as a **draft**, labeled `upkeep`, with a concise body that states: 1. the selected task and why, quoting the script's `rationale` 2. the before and after measurement, with numbers 3. an explicit statement that no public API changed 4. anything you deliberately left alone +Keep the body to those facts unless a reviewer needs more context. Before finishing, verify the draft state, label, and body against the pull request's current state. Remove transient setup failures and other statements that later became false. + ## Stop Conditions Report and open nothing when any of these hold: diff --git a/.agents/skills/authoring-documentation/SKILL.md b/.agents/skills/authoring-documentation/SKILL.md index 9b1324cb6..af610acc8 100644 --- a/.agents/skills/authoring-documentation/SKILL.md +++ b/.agents/skills/authoring-documentation/SKILL.md @@ -28,7 +28,7 @@ You MUST review @projects/site/src/docs/internal/guidelines/documentation.md bef ### How Vale Runs -- **Pre-commit** (via lint-staged):automatically lints staged `*.ts` and `*.md` files +- **Pre-commit** (via lint-staged): automatically lints staged non-test `*.ts` and `*.md` files - **CI**:runs as part of `pnpm run ci` - **Manual**:`pnpm run lint:vale` from the repo root (pass specific files as args to narrow scope) diff --git a/.agents/skills/authoring-projects/SKILL.md b/.agents/skills/authoring-projects/SKILL.md new file mode 100644 index 000000000..5579be6b2 --- /dev/null +++ b/.agents/skills/authoring-projects/SKILL.md @@ -0,0 +1,60 @@ +--- +name: authoring-projects +description: Author a new standalone NVIDIA Elements workspace project containing a Lit component, including package files, the five test types, Wireit tasks, workspace and CI registration, metadata generation, documentation-site integration, dependency-lint configuration, and generated lock/notice files. Use when creating or scaffolding a new `projects/` package based on the standalone component-library pattern. Do not use for adding a component to an existing project; use the component-creation skill instead. +--- + +# Authoring Projects + +Create a private-by-default project from a current in-repository reference, then wire every consumer before customizing the component. + +## Required context + +1. Read the repository `AGENTS.md`. +2. Read [references/integration-checklist.md](references/integration-checklist.md). +3. Use the `component-creation` skill for the component class, API, examples, and five test files. +4. Use the `build-system` skill for Wireit changes and the `testing` skill when modifying tests. +5. Read `projects/internals/RELEASE.md` only when the user wants the package published. + +## Workflow + +1. Confirm the kebab-case project/component name, purpose, package description, and whether the project is private or publish-ready. Default the component name to the project name and keep a new scaffold private. +2. Inspect `git status`, confirm `projects/` does not exist, and preserve unrelated work. Study the closest current standalone component package; default to `projects/plot` only when no closer reference exists. +3. Preview the deterministic scaffold from the repository root: + + ```shell + node .agents/skills/authoring-projects/scripts/scaffold-project.mjs \ + --name \ + --description "" \ + --dry-run + ``` + +4. Review the planned paths, then rerun without `--dry-run`. Pass `--reference ` when another standalone project is a better structural match. +5. Customize the placeholder component through the `component-creation` workflow. Keep `index.ts` side-effect free, isolate registration in `define.ts`, use `@since 0.0.0`, and make the `@documentation` URL match the generated site route. +6. Refresh generated workspace state: + + ```shell + mise exec -- pnpm install + mise exec -- pnpm run format:fix + mise exec -- pnpm run notice + ``` + +7. Run the project checks listed in its `DEVELOPMENT.md`, then verify the repository integrations: + + ```shell + mise exec -- pnpm -C projects/ run ci + mise exec -- pnpm -C projects/ run test:lighthouse + mise exec -- pnpm -C projects/internals/metadata run build + mise exec -- pnpm -C projects/site run build + mise exec -- pnpm run lint:knip + ``` + +8. Use targeted project checks for isolated changes. Run broader CI when changes affect shared infrastructure, build or release configuration, cross-project interfaces, generated artifacts, or more than one package. Inspect the final diff for generated artifacts, missing build edges, copied visual baselines, and unrelated lockfile churn. + +## Guardrails + +- Never overwrite an existing project or reuse another component's visual baselines. +- Declare every generated metadata/site input with its matching Wireit build dependency. +- Keep site-only workspace packages in `projects/site` dev dependencies and its workspace-scoped dependency-lint ignore because Eleventy creates their imports dynamically. +- Include CSS linting and use the repository-root stylelint path (`../../stylelint.config.mjs`) from a top-level project. +- Do not create tags, publish packages, edit release wiring, or make a private project public without explicit user authorization. +- Treat commit `c88b885b` as historical evidence, not a byte-for-byte template; the checklist records the defects that the scaffold must avoid. diff --git a/.agents/skills/authoring-projects/references/integration-checklist.md b/.agents/skills/authoring-projects/references/integration-checklist.md new file mode 100644 index 000000000..bab0126e5 --- /dev/null +++ b/.agents/skills/authoring-projects/references/integration-checklist.md @@ -0,0 +1,50 @@ +# Standalone component project checklist + +Use this checklist after scaffolding and before final verification. Commit `c88b885b` (`chore(ci): setup scene and plot`) established the reference integration surface, but this checklist corrects its omissions. + +## Project package + +- Create `projects//` with package metadata, exports, `sideEffects`, Wireit tasks, TypeScript/Vite/Vitest configs, README, CHANGELOG, DEVELOPMENT, NOTICE, and `.gitignore`. +- Create a side-effect-free package `src/index.ts` exporting `VERSION = '0.0.0'` and its unit/Lighthouse coverage. +- Create the component's class, CSS, example, `define.ts`, `index.ts`, and unit, axe, visual, SSR, and Lighthouse tests. +- Keep the package private until release is explicitly requested. +- List every supported command in `DEVELOPMENT.md`, including visual and SSR tests. +- Make `lint` depend on both `lint:eslint` and `lint:style`; use `../../stylelint.config.mjs` from `projects/`. +- Set component metadata and JSDoc to version `0.0.0`. Route documentation to `/elements/docs//` when the page is `projects/site/src/docs//index.md`. + +## Repository wiring + +| Consumer | Required change | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm-workspace.yaml` | Add `projects/`. | +| Root `package.json` | Add `:ci`, `:test:lighthouse`, and `:lint:fix` dependencies. | +| `knip.config.js` | Add the project workspace entry. Add the package to the site's scoped `ignoreDependencies` when Eleventy generates site imports. | +| Metadata `api.utils.ts` | Add the project to the API project list. | +| Metadata `package.json` | Add package/custom-elements inputs and a matching `:build` dependency. | +| Site `package.json` | Add dist inputs, a matching build dependency, and the workspace dev dependency. | +| Site docs | Add `projects/site/src/docs//index.md` with a matching `nve-` tag. | +| Generated files | Run pnpm install and the notice generator to update `pnpm-lock.yaml`, root NOTICE, and project NOTICE files. | + +## Historical traps to avoid + +- The reference commit added both metadata inputs but only the `scene` build edge. Add one build edge per project input. +- Eleventy generates package imports into HTML. The site needs an actual workspace dependency even though dependency lint cannot observe a source import; use a site-scoped ignore. +- The reference package inherited a dormant `../../../stylelint.config.mjs` path and omitted `lint:style` from `lint`. Use the two-level root path and run CSS linting. +- The starter component used `@since 0.10.0` and a documentation URL that did not match its site route. New scaffolds start at `0.0.0` and keep route/file placement aligned. +- Do not copy `.visual/*.png`; generate baselines from the new component after it renders meaningful output. +- A private scaffold is not release-ready. Do not infer authorization to publish it. + +## Publish-ready additions + +Only when requested, follow `projects/internals/RELEASE.md` and current repository examples to: + +- remove or change `private` intentionally; +- add the root semantic-release task and dependency ordering; +- add the commitlint scope; +- add build artifacts and JUnit reports to `.github/workflows/ci.yml`; +- verify the release configuration and package files; +- tell the user that the initial remote tag still requires explicit external action. + +## Verification + +Run commands through mise. At minimum, verify install, format, notice generation, project CI, project Lighthouse, metadata build, site build, dependency lint, and `git diff --check`. Browser-backed checks may require permission outside a sandbox. Do not raise bundle limits until measured output justifies a change. diff --git a/.agents/skills/authoring-projects/scripts/scaffold-project.mjs b/.agents/skills/authoring-projects/scripts/scaffold-project.mjs new file mode 100644 index 000000000..f8f552bca --- /dev/null +++ b/.agents/skills/authoring-projects/scripts/scaffold-project.mjs @@ -0,0 +1,417 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const GENERATED_REFERENCE_PATHS = ['.eslintcache', '.visual/', '.wireit/', 'coverage/', 'dist/', 'node_modules/']; + +const options = parseArgs(process.argv.slice(2)); + +if (options.help) { + printUsage(); + process.exit(0); +} + +const name = options.name; +const referenceName = options.reference ?? 'plot'; +const description = options.description ?? `A ${name} component.`; + +assertKebabCase(name, '--name'); +assertKebabCase(referenceName, '--reference'); +if (description.includes('\n')) fail('--description must be a single line.'); +if (description.includes('*/')) fail('--description must not contain "*/".'); + +const root = findRepositoryRoot(options.root ?? process.cwd()); +const projectRelative = `projects/${name}`; +const projectDirectory = path.join(root, projectRelative); +const referenceRelative = `projects/${referenceName}`; +const referenceDirectory = path.join(root, referenceRelative); + +if (existsSync(projectDirectory)) fail(`${projectRelative} already exists.`); +if (!existsSync(referenceDirectory)) fail(`${referenceRelative} does not exist.`); +if (!existsSync(path.join(referenceDirectory, 'src', referenceName, `${referenceName}.ts`))) { + fail(`${referenceRelative} is not a single-component project named ${referenceName}.`); +} + +const className = toPascalCase(name); +const referenceClassName = toPascalCase(referenceName); +const planned = new Map(); + +copyReferenceProject(); +normalizeProjectFiles(); +wireRepositoryConsumers(); +createDocumentationPage(); + +const changes = [...planned.entries()] + .filter(([absolutePath, content]) => { + if (!existsSync(absolutePath)) return true; + return readFileSync(absolutePath, 'utf8') !== content; + }) + .sort(([a], [b]) => a.localeCompare(b)); + +if (changes.length === 0) fail('The scaffold produced no changes.'); + +for (const [absolutePath] of changes) { + const action = existsSync(absolutePath) ? 'update' : 'create'; + console.log(`${options.dryRun ? 'would ' : ''}${action} ${path.relative(root, absolutePath)}`); +} + +if (options.dryRun) { + console.log(`\nDry run complete: ${changes.length} files would change.`); + process.exit(0); +} + +applyChanges(changes); +console.log(`\nCreated ${projectRelative} and wired ${changes.length} files.`); +console.log( + 'Next: run pnpm install, format:fix, notice, project CI, project Lighthouse, metadata generation, site build, and lint:knip through mise.' +); + +function parseArgs(args) { + const parsed = { dryRun: false, help: false }; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--dry-run') { + parsed.dryRun = true; + continue; + } + if (argument === '--help' || argument === '-h') { + parsed.help = true; + continue; + } + if (!['--name', '--reference', '--description', '--root'].includes(argument)) { + fail(`Unknown argument: ${argument}`); + } + const value = args[index + 1]; + if (!value || value.startsWith('--')) fail(`Missing value for ${argument}.`); + parsed[argument.slice(2)] = value; + index += 1; + } + + if (!parsed.help && !parsed.name) fail('--name is required.'); + return parsed; +} + +function printUsage() { + console.log(`Usage: + scaffold-project.mjs --name [options] + +Options: + --description Package and component description + --reference Existing single-component project (default: plot) + --root Repository root (default: discover from cwd) + --dry-run Print planned changes without writing + --help Show this help`); +} + +function assertKebabCase(value, option) { + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)) { + fail(`${option} must use kebab-case.`); + } +} + +function findRepositoryRoot(start) { + let current = path.resolve(start); + while (true) { + if (existsSync(path.join(current, 'pnpm-workspace.yaml')) && existsSync(path.join(current, 'projects', 'core'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) fail('Could not find the Elements repository root.'); + current = parent; + } +} + +function copyReferenceProject() { + const tracked = execFileSync('git', ['-C', root, 'ls-files', '--', referenceRelative], { + encoding: 'utf8' + }) + .trim() + .split('\n') + .filter(Boolean); + + if (tracked.length === 0) fail(`Git has no tracked files under ${referenceRelative}.`); + + for (const sourceRelative of tracked) { + const withinReference = path.relative(referenceRelative, sourceRelative); + if (GENERATED_REFERENCE_PATHS.some(prefix => withinReference === prefix || withinReference.startsWith(prefix))) { + continue; + } + + const source = path.join(root, sourceRelative); + const destinationWithinProject = replaceTokens(withinReference); + const destination = path.join(projectDirectory, destinationWithinProject); + const content = readFileSync(source, 'utf8'); + planned.set(destination, replaceTokens(content)); + } +} + +function normalizeProjectFiles() { + updatePlannedJson(`${projectRelative}/package.json`, packageJson => { + packageJson.name = `@nvidia-elements/${name}`; + packageJson.description = description; + packageJson.private = true; + packageJson.devDependencies = sortObject(packageJson.devDependencies); + + const lint = packageJson.wireit?.lint; + if (!lint) fail('Reference package is missing wireit.lint.'); + lint.dependencies = ['lint:eslint', 'lint:style']; + + const styleLint = packageJson.wireit?.['lint:style']; + if (!styleLint) fail('Reference package is missing wireit lint:style.'); + styleLint.command = styleLint.command.replaceAll('../../../stylelint.config.mjs', '../../stylelint.config.mjs'); + styleLint.files = styleLint.files.map(file => + file.replaceAll('../../../stylelint.config.mjs', '../../stylelint.config.mjs') + ); + }); + + const componentPath = `${projectRelative}/src/${name}/${name}.ts`; + let component = getPlannedText(componentPath); + component = component.replace(/ \* @description .*$/m, ` * @description ${sentence(description)}`); + component = component.replace( + / \* @documentation .*$/m, + ` * @documentation https://nvidia.github.io/elements/docs/${name}/` + ); + component = component.replace(/ \* @since .*$/m, ' * @since 0.0.0'); + component = component.replace(/^ \* @aria .*\n/m, ''); + planText(componentPath, component); + + planText(`${projectRelative}/README.md`, `# @nvidia-elements/${name}\n\n${sentence(description)}\n`); + + planText( + `${projectRelative}/DEVELOPMENT.md`, + `# Development + +| Command | Description | +| -------------------------- | ----------------------------------------- | +| \`pnpm run build\` | Build the library | +| \`pnpm run dev\` | Start development mode with file watching | +| \`pnpm run lint\` | Lint source files | +| \`pnpm run test\` | Run unit tests | +| \`pnpm run test:watch\` | Run unit tests in watch mode | +| \`pnpm run test:axe\` | Run accessibility tests | +| \`pnpm run test:coverage\` | Run unit tests with coverage | +| \`pnpm run test:visual\` | Run visual regression tests | +| \`pnpm run test:ssr\` | Run server-side rendering tests | +| \`pnpm run test:lighthouse\` | Run Lighthouse performance tests | +| \`pnpm run ci\` | Run the project CI pipeline | +` + ); +} + +function wireRepositoryConsumers() { + updateExistingJson('package.json', packageJson => { + addBefore( + packageJson.wireit.ci.dependencies, + `./projects/${name}:ci`, + dependency => dependency === './projects/site:ci' + ); + addBefore( + packageJson.wireit.lighthouse.dependencies, + `./projects/${name}:test:lighthouse`, + dependency => dependency === './projects/site:test:lighthouse' + ); + addBefore( + packageJson.wireit['lint:fix'].dependencies, + `./projects/${name}:lint:fix`, + dependency => dependency === './projects/site:lint:fix' + ); + }); + + let workspace = readExistingText('pnpm-workspace.yaml'); + const workspaceEntry = ` - projects/${name}\n`; + if (workspace.includes(workspaceEntry)) fail(`${projectRelative} is already in pnpm-workspace.yaml.`); + const workspaceMarker = ' - projects/styles\n'; + if (!workspace.includes(workspaceMarker)) fail('Could not find the projects/styles workspace marker.'); + workspace = workspace.replace(workspaceMarker, workspaceEntry + workspaceMarker); + planText('pnpm-workspace.yaml', workspace); + + updateKnipConfig(); + updateMetadataProjects(); + + updateExistingJson('projects/internals/metadata/package.json', packageJson => { + const task = packageJson.wireit?.['generate:api']; + if (!task) fail('Metadata package is missing generate:api.'); + addBefore(task.files, `../../${name}/package.json`, file => file === '../../patterns/package.json'); + addBefore(task.files, `../../${name}/dist/custom-elements.json`, file => file === '../../patterns/package.json'); + addUnique(task.dependencies, { script: `../../${name}:build`, cascade: false }, dependency => dependency.script); + }); + + updateExistingJson('projects/site/package.json', packageJson => { + const task = packageJson.wireit?.['build:dependencies']; + if (!task) fail('Site package is missing build:dependencies.'); + addBefore(task.files, `../${name}/dist/**/*.js`, file => file.startsWith('../internals/')); + addBefore(task.files, `../${name}/dist/**/*.examples.json`, file => file.startsWith('../internals/')); + addUnique(task.dependencies, { script: `../${name}:build`, cascade: false }, dependency => dependency.script); + packageJson.devDependencies[`@nvidia-elements/${name}`] = 'workspace:*'; + packageJson.devDependencies = sortObject(packageJson.devDependencies); + }); +} + +function updateKnipConfig() { + let config = readExistingText('knip.config.js'); + const workspaceMarker = ` 'projects/${name}': {`; + if (config.includes(workspaceMarker)) fail(`${projectRelative} is already in knip.config.js.`); + + const projectBlock = ` 'projects/${name}': { + entry: [ + ...PACKAGE_FILES, + ...SOURCE_INDEX, + ...DEFINE_ENTRIES, + ...EXAMPLE_ENTRIES, + ...TEST_ENTRIES, + ...TEST_VARIANT_ENTRIES, + ...VITE_CONFIGS, + ...VITEST_CONFIGS + ], + project: [...PROJECT_FILES, ...SOURCE_FILES] + }, +`; + const siteMarker = " 'projects/site': {"; + if (!config.includes(siteMarker)) fail('Could not find the projects/site Knip workspace.'); + config = config.replace(siteMarker, projectBlock + siteMarker); + + const siteStart = config.indexOf(siteMarker); + const siteEnd = config.indexOf("\n },\n 'projects/", siteStart); + if (siteEnd === -1) fail('Could not isolate the projects/site Knip workspace.'); + let siteBlock = config.slice(siteStart, siteEnd + '\n },'.length); + const packageName = `@nvidia-elements/${name}`; + + if (/ignoreDependencies: \[[^\]]*\]/s.test(siteBlock)) { + siteBlock = siteBlock.replace(/ignoreDependencies: \[([^\]]*)\]/s, (_match, values) => { + const dependencies = [...values.matchAll(/'([^']+)'/g)].map(match => match[1]); + if (!dependencies.includes(packageName)) dependencies.push(packageName); + dependencies.sort(); + return `ignoreDependencies: [${dependencies.map(value => `'${value}'`).join(', ')}]`; + }); + } else { + siteBlock = siteBlock.replace( + /( project: \[[^\n]+\])(\n)/, + `$1,\n ignoreDependencies: ['${packageName}']$2` + ); + } + + siteBlock = siteBlock.replace(/(ignoreDependencies: \[[^\]]*\])\s*\/\/ temporary/, '$1'); + + config = config.slice(0, siteStart) + siteBlock + config.slice(siteEnd + '\n },'.length); + planText('knip.config.js', config); +} + +function updateMetadataProjects() { + const relative = 'projects/internals/metadata/src/tasks/api.utils.ts'; + let source = readExistingText(relative); + const match = source.match(/ const projects = \[\n([\s\S]*?)\n \];/); + if (!match) fail('Could not find the metadata API project list.'); + const entry = ` '../../../../${name}'`; + if (match[1].includes(entry)) fail(`${name} is already in the metadata API project list.`); + const lines = match[1].split('\n'); + lines[lines.length - 1] = `${lines.at(-1)},`; + lines.push(entry); + source = source.replace(match[0], ` const projects = [\n${lines.join('\n')}\n ];`); + planText(relative, source); +} + +function createDocumentationPage() { + const relative = `projects/site/src/docs/${name}/index.md`; + if (existsSync(path.join(root, relative))) fail(`${relative} already exists.`); + planText( + relative, + `--- +{ + "title": "${className}", + "description": ${JSON.stringify(description)}, + "layout": "docs.11ty.js", + "tag": "nve-${name}" +} +--- +` + ); +} + +function updateExistingJson(relative, mutate) { + const value = JSON.parse(readExistingText(relative)); + mutate(value); + planText(relative, `${JSON.stringify(value, null, 2)}\n`); +} + +function updatePlannedJson(relative, mutate) { + const value = JSON.parse(getPlannedText(relative)); + mutate(value); + planText(relative, `${JSON.stringify(value, null, 2)}\n`); +} + +function getPlannedText(relative) { + const absolute = path.join(root, relative); + if (!planned.has(absolute)) fail(`No planned file found at ${relative}.`); + return planned.get(absolute); +} + +function readExistingText(relative) { + const absolute = path.join(root, relative); + if (!existsSync(absolute)) fail(`Required file does not exist: ${relative}.`); + return readFileSync(absolute, 'utf8'); +} + +function planText(relative, content) { + planned.set(path.join(root, relative), content); +} + +function addBefore(array, value, predicate) { + if (array.some(item => JSON.stringify(item) === JSON.stringify(value))) return; + const index = array.findIndex(predicate); + if (index === -1) array.push(value); + else array.splice(index, 0, value); +} + +function addUnique(array, value, key) { + const valueKey = key(value); + if (!array.some(item => key(item) === valueKey)) array.push(value); +} + +function sortObject(value) { + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); +} + +function replaceTokens(value) { + return value.replaceAll(referenceClassName, className).replaceAll(referenceName, name); +} + +function sentence(value) { + const trimmed = value.trim(); + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +} + +function toPascalCase(value) { + return value + .split('-') + .map(part => part[0].toUpperCase() + part.slice(1)) + .join(''); +} + +function applyChanges(changesToApply) { + const originals = new Map(); + for (const [absolutePath] of changesToApply) { + if (existsSync(absolutePath)) originals.set(absolutePath, readFileSync(absolutePath, 'utf8')); + } + + try { + for (const [absolutePath, content] of changesToApply) { + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content, 'utf8'); + } + } catch (error) { + for (const [absolutePath, content] of originals) writeFileSync(absolutePath, content, 'utf8'); + for (const [absolutePath] of changesToApply) { + if (!originals.has(absolutePath)) rmSync(absolutePath, { recursive: true, force: true }); + } + rmSync(projectDirectory, { recursive: true, force: true }); + throw error; + } +} + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} diff --git a/.agents/skills/guidance-build-system/SKILL.md b/.agents/skills/guidance-build-system/SKILL.md index 75002613a..6bf30d088 100644 --- a/.agents/skills/guidance-build-system/SKILL.md +++ b/.agents/skills/guidance-build-system/SKILL.md @@ -30,6 +30,10 @@ pnpm run ci:reset # Build a single project cd projects/core && pnpm run build +# Force that project's Wireit scripts to rerun +mise exec -- git clean -dfX -- .wireit +mise exec -- pnpm run build + # Dev mode with watch cd projects/core && pnpm run dev ``` @@ -43,7 +47,7 @@ Wireit configs live in each project's `package.json` under the `wireit` key. Eac - `files`:input file globs for cache invalidation - `output`:output file globs that get cached -Wireit skips tasks whose inputs have not changed since the last run. To force a rebuild, delete `.wireit/` in the project directory. +Wireit skips tasks whose inputs have not changed since the last run. `WIREIT_CACHE=none` turns off output caching but does not bypass this incremental freshness check. To force a rerun, run `mise exec -- git clean -dfX -- .wireit` from the target project directory before invoking the project script. ### Dependency Patterns @@ -66,7 +70,7 @@ Cross-package dependencies use the `: