diff --git a/.agents/knowledge/evals/knowledge-checks.md b/.agents/knowledge/evals/knowledge-checks.md new file mode 100644 index 000000000000..5d1ab9a7208e --- /dev/null +++ b/.agents/knowledge/evals/knowledge-checks.md @@ -0,0 +1,54 @@ +--- +id: evals.knowledge-checks +summary: Catalog of the KC checks that knowledge.mts runs on agent knowledge files, with severity and how to fix each failure. +kind: eval +status: active +load_when: A knowledge check fails, or you are changing knowledge.mts or its checks +sources: + - .agents/knowledge/tools/knowledge.mts#runChecks + - .agents/knowledge/taxonomy.yaml#checks + - .github/workflows/microsoft-pr.yml#javascript-tests +verified: 2026-10-29 +rules: [fix-not-suppress] +related: [tooling.format-lint] +--- + + + +# Knowledge checks + +Use when: `knowledge.mts check` reports a failure, or you change a check. Not for: writing conventions; see the organize skill's [conventions](../../skills/rnmacos-organize-knowledge/references/conventions.md). + +## Rules + +- **evals.knowledge-checks.fix-not-suppress:** Fix the content that fails a check. Change a check's severity in `taxonomy.yaml` only with maintainer approval, and record why in the PR. + +## Catalog + +Checks are errors unless `checks` in [taxonomy.yaml](../taxonomy.yaml) lowers them. CI runs `check --ci` on tracked files only. + +| Check | What it enforces | Typical fix | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| KC-01 | Frontmatter on line 1, required topic keys, no `title`, pragma placement, exactly one H1, a `Use when:` line | Match the topic template | +| KC-02 | Topic and rule IDs are well formed, registered, unique, and not retired; `requires` and `related` resolve | Rename, register the domain, or add a redirect | +| KC-03 | Index tables and path pointers match a fresh generation | `knowledge.mts index --write` | +| KC-04 | Relative links and heading anchors resolve | Fix the path or heading | +| KC-05 | Cited sources and backticked repository paths exist (warning) | Update the citation | +| KC-06 | `yarn ` in code is a root script, root binary, or Yarn built-in, and doesn't pass arguments to a script that shadows a binary (warning) | Use a real command, `yarn exec `, or add it to `yarn_allow` | +| KC-07 | Size budgets; 80% warns | Split, or move detail to a reference | +| KC-08 | Skill name, prefix, description, frontmatter allowlist, reference depth, listing in `AGENTS.md`, and one `.claude/skills` symlink per skill | Follow the skill template | +| KC-09 | Copilot pointer links only to `AGENTS.md`; path pointers have valid `applyTo` globs | Regenerate pointers | +| KC-10 | No `@path` imports | Use a Markdown link | +| KC-11a | No exact duplicate bullets across knowledge files | Keep one copy and link to it | +| KC-11b | Near-duplicate text (advisory, `report --similar`) | Merge or link | +| KC-12 | `AGENTS.md` keeps upstream's first line and one fork block; no fork content in upstream-owned files | Move content into the block or a topic | +| KC-13 | `AGENTS.md` links the index; routes target active topics | Fix the route or link | +| KC-14 | Topic `asserts` hold against the repository (warning) | Re-verify the topic | +| KC-15 | Freshness: verification age and source changes (advisory, `report --stale`) | Re-verify and bump `verified` | + +## Verify + +```sh +node .agents/knowledge/tools/knowledge.mts check +node --test .agents/knowledge/tools/knowledge.test.mts +``` diff --git a/.agents/knowledge/fork/diff-tags.md b/.agents/knowledge/fork/diff-tags.md new file mode 100644 index 000000000000..1c419d11857c --- /dev/null +++ b/.agents/knowledge/fork/diff-tags.md @@ -0,0 +1,51 @@ +--- +id: fork.diff-tags +summary: How to mark macOS changes in shared upstream files so merges and audits can find every fork diff. +kind: rules +status: active +load_when: Changing a file that upstream React Native also has, or adding a fork-only file +sources: + - docsite/docs/contributing/diffs-with-upstream.md + - .flowconfig:72-76 + - .yarnrc.yml:9 + - packages/react-native/ReactApple/Libraries/RCTUIKit/README.md:3 +verified: 2026-10-28 +rules: [tag-every-change, match-comment-syntax, keep-balanced, tag-new-files] +related: [fork.ownership] +--- + + + +# Diff tags + +Use when: you change a file that upstream also has, or you add a fork-only file. Not for: deciding who owns a path; see [fork.ownership](ownership.md). + +The canonical syntax, with examples, is on the docsite page [How React Native macOS differs from React Native](../../../docsite/docs/contributing/diffs-with-upstream.md). This topic adds agent rules and observed exceptions. + +## Rules + +- **fork.diff-tags.tag-every-change:** Mark each macOS change in a shared file with `[macOS]` on one line, or `[macOS` … `macOS]` around a block, so `git grep "\[macOS"` finds every fork diff. +- **fork.diff-tags.match-comment-syntax:** Use the file's own comment syntax: `//` in Objective-C, C++, and JS; `#` in Ruby, YAML, and shell; `;` in `.flowconfig`; `` in Markdown. Where a file already has tags, match them. +- **fork.diff-tags.keep-balanced:** Every `[macOS` block opener needs a `macOS]` closer. Count both before and after you edit. +- **fork.diff-tags.tag-new-files:** Tag a fork-only file once, near the top, instead of tagging each change in it. + +## Facts + +- `#if` blocks carry the tag on the directive. macOS-only code uses `#if TARGET_OS_OSX // [macOS` … `#endif // macOS]`. Original iOS-only code uses `#if !TARGET_OS_OSX // [macOS]` with a single-line tag, because the code inside is upstream's. +- `[visionOS]` marks visionOS diffs. Some lines carry both, such as `TARGET_OS_IOS // [macOS] [visionOS]` for APIs that exist only on iOS. +- On 2026-10-28, 675 tracked files contained `[macOS` and 31 contained `[visionOS`. +- `.flowconfig` and `.yarnrc.yml` open blocks with `[macOS] comment` rather than the docsite's `[macOS comment`. Prefer the docsite form in new code. + +## Pitfalls + +- Don't reformat, reorder, or rewrap upstream lines near your change; every extra changed line can conflict in the next merge. +- Remove a tag only when the code matches upstream again. +- Opener and closer counts can differ legitimately (for example, a tag inside a string). Treat a mismatch as a reason to read the file, not as proof of an error. + +## Verify + +```sh +git grep -n "\[macOS" -- path/to/file +grep -oE '\[macOS([^]]|$)' path/to/file | wc -l # block openers +grep -oE '(^|[^[])macOS\]' path/to/file | wc -l # block closers +``` diff --git a/.agents/knowledge/fork/ownership.md b/.agents/knowledge/fork/ownership.md new file mode 100644 index 000000000000..b8cd38d46953 --- /dev/null +++ b/.agents/knowledge/fork/ownership.md @@ -0,0 +1,48 @@ +--- +id: fork.ownership +summary: Which paths the fork owns, which it shares with upstream, and which it must never edit. +kind: rules +status: active +load_when: Adding a file, choosing where fork content goes, or editing configs that upstream also ships +sources: + - .agents/knowledge/taxonomy.yaml#upstream_owned + - docsite/docs/contributing/diffs-with-upstream.md + - https://github.com/react/react-native/blob/main/AGENTS.md +verified: 2026-10-29 +rules: + [check-upstream-first, never-edit-upstream-owned, prefer-fork-owned-paths] +related: [fork.diff-tags] +--- + + + +# Path ownership + +Use when: you add a file, move fork content, or edit a file that upstream also ships. Not for: tag syntax; see [fork.diff-tags](diff-tags.md). + +## Rules + +- **fork.ownership.check-upstream-first:** Before you edit a path, find out whether upstream has it. If it does, the path is shared: keep the change small and tag it. +- **fork.ownership.never-edit-upstream-owned:** Never put fork content in the paths listed under `ownership.upstream_owned` in [taxonomy.yaml](../taxonomy.yaml). The knowledge checks (KC-12) reject `[macOS`, `rnmacos-`, and `.agents/knowledge` there. +- **fork.ownership.prefer-fork-owned-paths:** Put large macOS-only code and fork tooling in fork-owned paths, so merges never touch it. + +## Facts + +- Upstream is `react/react-native`, formerly `facebook/react-native`. +- **Fork-owned** paths don't exist upstream: `docsite/`, `.ado/`, `.github/workflows/microsoft-*.yml`, `packages/react-native-macos-init/`, `packages/react-native/ReactApple/Libraries/RCTUIKit/`, `*.macos.js` files, `.agents/`, and the `.claude/skills/rnmacos-*` symlinks. +- **Shared** paths exist in both repositories, including most of `packages/react-native/React/` and `packages/react-native/Libraries/`. Edit them with [diff tags](diff-tags.md). +- **Upstream-owned** paths are shared paths the fork never changes: `__docs__/` architecture notes and the shared lint and format configs. +- Upstream ships a root `AGENTS.md`. The fork's copy keeps upstream's `# React Native` first line and adds a single `[macOS` block, so the 0.88 merge touches as little as possible. + +## Pitfalls + +- Fork content in an upstream-owned file conflicts with every merge. +- A shared folder can hold fork-only files: `packages/react-native/ReactApple/` exists upstream, but its `RCTUIKit/` subfolder doesn't. Check the exact path. + +## Verify + +```sh +# Exits 0 if the path exists on upstream main. +gh api "repos/react/react-native/contents/?ref=main" --silent +node .agents/knowledge/tools/knowledge.mts check +``` diff --git a/.agents/knowledge/fork/upstream-sync.md b/.agents/knowledge/fork/upstream-sync.md new file mode 100644 index 000000000000..0d4f5f98f866 --- /dev/null +++ b/.agents/knowledge/fork/upstream-sync.md @@ -0,0 +1,51 @@ +--- +id: fork.upstream-sync +summary: Classify local fixes from a version-sync branch before forwarding them to main. +kind: procedure +status: active +load_when: Reviewing a large upstream version merge or deciding which release fixes to forward-port to main +sources: + - docsite/docs/releases/minor-release.md + - https://github.com/microsoft/react-native-macos/pull/3022#discussion_r3719817893 + - https://github.com/microsoft/react-native-macos/pull/3022#discussion_r3722730469 + - https://github.com/microsoft/react-native-macos/pull/3099 +verified: 2026-10-29 +rules: [classify-each-fix, verify-target-branch, preserve-branch-config] +related: [fork.diff-tags, fork.ownership, process.pull-requests] +--- + + + +# Forward-porting release fixes + +Use when: you review local fixes on a version-sync branch and decide what belongs on `main`. Not for: merging upstream itself; follow the [minor-release procedure](../../../docsite/docs/releases/minor-release.md). + +## Rules + +- **fork.upstream-sync.classify-each-fix:** Classify every locally authored fix against the exact target branch; don't forward-port a whole release branch or infer applicability from a commit subject. +- **fork.upstream-sync.verify-target-branch:** Inspect the target branch's implementation and later merge branches before proposing a forward-port; cite the commit, file, or call site that supports the classification. +- **fork.upstream-sync.preserve-branch-config:** Keep release-specific configuration on its release branch and verify target-branch settings before copying config changes. + +## Procedure + +1. Identify the locally authored commits in the sync PR, separately from imported upstream history. Use the merge base and inspect the PR's local commits. +2. Classify each local fix as one of: + - Already present on `main`: cite the matching commit or implementation. + - Already fixed in a later merge branch: cite that branch and fix; don't duplicate it. + - Applicable to `main`: propose a separate forward-port PR, and note its dependencies or stack. + - Release-only: keep version bumps and branch-specific release configuration on the stable branch. + - Not applicable: the target has no corresponding file, implementation, or call site; don't invent one just to carry the patch. +3. Split commits that mix categories when needed. For each decision, inspect the target branch itself and any relevant later merge branch; don't treat an open PR as already merged. +4. Verify configuration against the target. For example, Changesets `baseBranch` is `origin/0.83-stable` on that stable branch and `origin/main` on `main`. +5. Run the relevant checks on each forward-port target. Report the classification, evidence, validation, and any unmerged follow-up. + +## Facts + +- The 0.83 sync review in [#3022](https://github.com/microsoft/react-native-macos/pull/3022) separated changes already in `main`, fixes to forward-port, stable-only changes, fixes already present in later merge branches, and changes with no matching implementation. The review also kept the stable Changesets base branch distinct from `main`. +- In [#3099](https://github.com/microsoft/react-native-macos/pull/3099), shared fixes were submitted separately to `main`; the review tracked that they were not yet merged rather than calling them landed. + +## Pitfalls + +- A fix present in a later `*-merge` branch may not belong on `main` yet; trace where the behavior first applies and whether it is already guarded. +- An open forward-port PR is not evidence that the fix has landed. Track its status and stack explicitly. +- A stable-only setting such as Changesets `baseBranch` can break `main` if copied without checking its branch-specific value. diff --git a/.agents/knowledge/index.md b/.agents/knowledge/index.md new file mode 100644 index 000000000000..446ed9b1a623 --- /dev/null +++ b/.agents/knowledge/index.md @@ -0,0 +1,42 @@ +# react-native-macos agent knowledge + +Start here when [AGENTS.md](../../AGENTS.md) doesn't answer your question. Open only the topics whose "Load when" matches your task, plus the topics they list under `requires`. + +To add, change, move, or retire knowledge, use the [`rnmacos-organize-knowledge`](../skills/rnmacos-organize-knowledge/SKILL.md) skill. To search every knowledge home, run `node .agents/knowledge/tools/knowledge.mts find `. + +Vocabulary, budgets, and routes are in [taxonomy.yaml](taxonomy.yaml). The tables below are generated by `knowledge.mts index --write`; don't edit them by hand. + +## Topics + + + +| Topic | Load when | Summary | +| --- | --- | --- | +| [evals.knowledge-checks](evals/knowledge-checks.md) | A knowledge check fails, or you are changing knowledge.mts or its checks | Catalog of the KC checks that knowledge.mts runs on agent knowledge files, with severity and how to fix each failure. | +| [fork.diff-tags](fork/diff-tags.md) | Changing a file that upstream React Native also has, or adding a fork-only file | How to mark macOS changes in shared upstream files so merges and audits can find every fork diff. | +| [fork.ownership](fork/ownership.md) | Adding a file, choosing where fork content goes, or editing configs that upstream also ships | Which paths the fork owns, which it shares with upstream, and which it must never edit. | +| [fork.upstream-sync](fork/upstream-sync.md) | Reviewing a large upstream version merge or deciding which release fixes to forward-port to main | Classify local fixes from a version-sync branch before forwarding them to main. | +| [process.pull-requests](process/pull-requests.md) | Writing a commit or PR title or body, adding a Changeset, or backporting to a stable branch | PR titles, the PR body template, Changesets, and backport conventions for react-native-macos. | +| [testing.rntester-macos](testing/rntester-macos.md) | Building, running, or debugging RNTester-macOS, or validating a native macOS change | Install Pods for and build RNTester-macOS the way CI does, then run it with Metro. | +| [tooling.commands](tooling/commands.md) | Choosing a command to install, build, test, lint, or format, or when a Yarn command fails | Yarn 4 setup and the root commands for building, testing, linting, and formatting react-native-macos. | +| [tooling.format-lint](tooling/format-lint.md) | Formatting or linting a file, or writing Markdown with frontmatter | Prettier pragma, markdownlint scope, and ESLint and Flow gaps for react-native-macos files, including agent knowledge files. | + + + +## Skills + + + +| Skill | Use when | +| --- | --- | +| [`rnmacos-organize-knowledge`](../skills/rnmacos-organize-knowledge/SKILL.md) | Record, update, move, or retire react-native-macos agent knowledge in AGENTS.md, .agents/knowledge topics, rnmacos-* skills, and path pointers. | + + + +## Path routes + + + +No path routes yet. + + diff --git a/.agents/knowledge/process/pull-requests.md b/.agents/knowledge/process/pull-requests.md new file mode 100644 index 000000000000..f871a90281e4 --- /dev/null +++ b/.agents/knowledge/process/pull-requests.md @@ -0,0 +1,45 @@ +--- +id: process.pull-requests +summary: PR titles, the PR body template, Changesets, and backport conventions for react-native-macos. +kind: rules +status: active +load_when: Writing a commit or PR title or body, adding a Changeset, or backporting to a stable branch +sources: + - .github/PULL_REQUEST_TEMPLATE.md + - .github/workflows/microsoft-pr.yml#commitlint-lite + - .github/workflows/microsoft-pr.yml#check-changesets + - docsite/docs/contributing/backporting.md + - docsite/docs/contributing/versioning.md +verified: 2026-10-28 +rules: [conventional-title, fill-template, changesets-for-stable] +related: [tooling.commands] +--- + + + +# Pull requests + +Use when: you write a PR title or body, add a Changeset, or backport a change. Not for: choosing which checks to run; see [tooling.commands](../tooling/commands.md). + +## Rules + +- **process.pull-requests.conventional-title:** Write PR titles as Conventional Commits, such as `fix(textinput): handle focus correctly`. CI lints the title with `@rnx-kit/commitlint-lite`. +- **process.pull-requests.fill-template:** Fill in the template's `## Summary:` and `## Test Plan:` sections. The Test Plan lists each exact command you ran with its result, plus screenshots for UI changes. +- **process.pull-requests.changesets-for-stable:** Add a Changeset with `yarn change` only when the PR targets a `*-stable` branch. PRs into `main` don't need one. + +## Facts + +- The template asks contributors to first consider fixing the problem upstream, or in a layer above react-native-macos. +- PR CI (`microsoft-pr.yml`) runs on PRs into `main`, `*-stable`, `release/*`, and `*-merge` branches. +- Backport branches are named `/`, such as `0.81/fix-text-input`, and titles add the version to the scope, such as `fix(0.81, textinput): …`. The full procedure is in [Backporting](../../../docsite/docs/contributing/backporting.md). +- Minor versions track upstream React Native minor versions; patch versions differ. + +## Pitfalls + +- A PR from a `changeset-release/` branch is the version bump itself and skips the Changeset check. + +## Verify + +```sh +yarn change:check +``` diff --git a/.agents/knowledge/taxonomy.yaml b/.agents/knowledge/taxonomy.yaml new file mode 100644 index 000000000000..c8193ea2ba97 --- /dev/null +++ b/.agents/knowledge/taxonomy.yaml @@ -0,0 +1,94 @@ +# @format +# Machine-readable vocabulary, budgets, and routes for react-native-macos agent +# knowledge. knowledge.mts reads this file. Prose conventions live in +# .agents/skills/rnmacos-organize-knowledge/references/conventions.md. + +version: 1 +audited: 2026-10-28 + +kinds: [rules, reference, procedure, decision, eval] +statuses: [draft, active, deprecated] + +domains: + fork: Fork model, diff tags, path ownership, and upstream merges + apple: macOS divergences from the React Native iOS implementation + a11y: macOS accessibility rules, divergences, and exceptions + tooling: Yarn, formatting, linting, and CI + testing: What to run, native test targets, and RNTester-macOS + process: Pull requests, Changesets, backports, and releases + evals: Knowledge checks and behavior scenarios + +skills: + root: .agents/skills + # Host-specific folders that hold only a relative symlink to each skill. + links: [.claude/skills] + prefix: rnmacos- + frontmatter: + [ + name, + description, + license, + metadata, + allowed-tools, + argument-hint, + disable-model-invocation, + ] + +budgets: + warn_ratio: 0.8 + agents_fork_block: {lines: 60, bytes: 4096} + agents_total: {lines: 150, bytes: 12288} + tier0: {bytes: 16384} + copilot_pointer: {lines: 8} + instructions_pointer: {lines: 12, bytes: 1024} + index: {lines: 100} + topic: {lines: 150, bytes: 8192} + skill: {lines: 150, bytes: 6144} + reference: {lines: 200, bytes: 10240} + skill_description: {warn: 400, max: 1024} + +skill_budgets: + rnmacos-organize-knowledge: {lines: 80} + +# Severity overrides. KC-05 and KC-06 warn during burn-in (10 PRs, including +# one *-merge PR); KC-14 becomes an error in phase 3. +checks: + KC-05: warning + KC-06: warning + KC-14: warning + +freshness: + verified_days: 180 + draft_days: 30 + audit_days: 45 + +ownership: + upstream_owned: + - '**/__docs__/**' + - .markdownlint-cli2.mjs + - .prettierrc.js + - .expo-code-review/** + - packages/react-native-compatibility-check/AGENTS.md + +# Places `find` searches. .ai/ holds legacy commands until they move to skills. +homes: + - AGENTS.md + - .github/copilot-instructions.md + - .github/instructions + - .agents/knowledge + - .agents/skills + - .ai + - docsite/docs + +# Path routes generate .github/instructions/rnmacos-.instructions.md. +routes: [] + +redirects: {} +retired_ids: [] + +# KC-06 accepts these `yarn ` commands even though they aren't root scripts or binaries. +yarn_allow: [] + +# KC-05 accepts these paths, which knowledge files mention on purpose even though they don't exist, +# such as discovery folders the fork chose not to create. +absent_paths: [.github/skills, .claude/rules] diff --git a/.agents/knowledge/testing/rntester-macos.md b/.agents/knowledge/testing/rntester-macos.md new file mode 100644 index 000000000000..b80055bf3ec8 --- /dev/null +++ b/.agents/knowledge/testing/rntester-macos.md @@ -0,0 +1,54 @@ +--- +id: testing.rntester-macos +summary: Install Pods for and build RNTester-macOS the way CI does, then run it with Metro. +kind: procedure +status: active +load_when: Building, running, or debugging RNTester-macOS, or validating a native macOS change +sources: + - .github/workflows/microsoft-build-rntester.yml#RCT_NEW_ARCH_ENABLED + - .ado/scripts/xcodebuild.sh#derivedDataPath + - packages/rn-tester/Podfile:6-28 + - packages/rn-tester/js/utils/RNTesterList.macos.js +verified: 2026-10-28 +rules: [build-like-ci, open-workspace] +related: [tooling.commands] +--- + + + +# RNTester-macOS + +Use when: you build or run RNTester-macOS, or you need to prove a native macOS change compiles. Not for: Jest or other JavaScript checks; see [tooling.commands](../tooling/commands.md). + +## Rules + +- **testing.rntester-macos.build-like-ci:** Install Pods with `RCT_NEW_ARCH_ENABLED=1` and build through `.ado/scripts/xcodebuild.sh`, as CI does. Report the exact commands you ran and their result. +- **testing.rntester-macos.open-workspace:** Open `packages/rn-tester/RNTesterPods.xcworkspace`, not the `.xcodeproj`. CocoaPods generates the workspace. + +## Procedure + +```sh +cd packages/rn-tester +bundle install +RCT_NEW_ARCH_ENABLED=1 bundle exec pod install +cd ../.. +.ado/scripts/xcodebuild.sh packages/rn-tester/RNTesterPods.xcworkspace macosx Debug RNTester-macOS build +yarn start # Metro, in a second terminal, before you launch the app +``` + +## Facts + +- CI builds three schemes on a `macos-26` runner: `RNTester-macOS` (`macosx`), `RNTester` (`iphonesimulator`), and `RNTester-visionOS` (`xrsimulator`). It builds them but runs no native tests. +- Each scheme builds with static libraries and with `USE_FRAMEWORKS=dynamic`. Dynamic builds are still in progress and don't fail the check. +- The wrapper passes `CODE_SIGNING_ALLOWED=NO`, writes derived data next to the workspace, and installs `xcbeautify` with Homebrew if it's missing. Set `USE_CCACHE=1` to build through ccache. +- The Podfile needs cmake and installs it with Homebrew if it's missing. It builds for iOS, macOS, and visionOS. +- The macOS example list re-exports the iOS list, so an example added to the iOS list also appears on macOS. + +## Pitfalls + +- After you change native dependencies or switch branches, run `pod install` again before building. +- A green macOS build doesn't prove iOS or visionOS still compile. When you change shared Apple code, build the `RNTester` scheme with `iphonesimulator` too. + +## Verify + +The wrapper exits 0 when the build succeeds, and the app launches with Metro running. diff --git a/.agents/knowledge/tooling/commands.md b/.agents/knowledge/tooling/commands.md new file mode 100644 index 000000000000..a7edc485f475 --- /dev/null +++ b/.agents/knowledge/tooling/commands.md @@ -0,0 +1,62 @@ +--- +id: tooling.commands +summary: Yarn 4 setup and the root commands for building, testing, linting, and formatting react-native-macos. +kind: reference +status: active +load_when: Choosing a command to install, build, test, lint, or format, or when a Yarn command fails +sources: + - package.json#scripts + - .yarnrc.yml#nodeLinker + - packages/react-native/package.json#engines + - .github/workflows/microsoft-pr.yml#node-version +verified: 2026-10-28 +rules: [use-yarn-bin, no-install-scripts] +related: [tooling.format-lint, testing.rntester-macos] +asserts: + - {file: .yarnrc.yml, yaml: nodeLinker, equals: pnpm} + - {file: package.json, json: packageManager, matches: '^yarn@4\.'} +--- + + + +# Commands + +Use when: you need a build, test, lint, or format command, or a Yarn command fails. Not for: Prettier and markdownlint rules; see [tooling.format-lint](format-lint.md). + +## Rules + +- **tooling.commands.use-yarn-bin:** Run root scripts and dependency binaries through Yarn from the repository root, such as `yarn jest` or `yarn markdownlint-cli2`. The pnpm linker creates no `node_modules/.bin`, so bare binary names fail and `npx` may fetch a different version. +- **tooling.commands.no-install-scripts:** Don't turn on `enableScripts` or add install scripts. When a package needs a dependency its manifest omits, add a third-party entry to `packageExtensions` in `.yarnrc.yml`; never add `@react-native/*` packages there. + +## Facts + +- Yarn 4 is pinned through `packageManager`, with `nodeLinker: pnpm`, `enableScripts: false`, and a 7-day minimum package age (`npmMinimalAgeGate`). Upstream uses Yarn v1. +- `react-native` requires Node `>= 20.19.4`. Fork CI uses Node 22. Scripts written as `.mts` run directly with `node file.mts`. + +| Purpose | Command | +| ------------------------ | ------------------------------------------------------------------------------------ | +| Install | `yarn install` | +| Jest, one file | `yarn jest path/to/file-test.js --runInBand` | +| Jest, one test | `yarn jest path/to/file-test.js -t 'name' --runInBand` | +| Flow | `yarn flow-check` | +| ESLint | `yarn lint` | +| TypeScript declarations | `yarn build-types`, then `yarn test-generated-typescript` and `yarn test-typescript` | +| Prettier | `yarn format-check` | +| CI shell and code checks | `yarn lint-ci` | +| Yarn constraints | `yarn constraints` | +| Changeset | `yarn change`, checked with `yarn change:check` | +| Metro for RNTester | `yarn start` | +| Android build and tests | `yarn build-android`, `yarn test-android` | + +## Pitfalls + +- `yarn test-ios` runs `scripts/objc-test.sh`, which targets the iOS simulator only. For macOS, follow [testing.rntester-macos](../testing/rntester-macos.md). +- `yarn clang-format` and `yarn format` rewrite every matching file in the repository. Format only the files you changed. +- A root script shadows a binary with the same name. `yarn prettier` runs the root `prettier` script even when you pass file paths, and that script writes every pragma file in the repository. Run `yarn exec prettier --check path/to/file` instead. + +## Verify + +```sh +yarn --version +node --version +``` diff --git a/.agents/knowledge/tooling/format-lint.md b/.agents/knowledge/tooling/format-lint.md new file mode 100644 index 000000000000..46f1a3604345 --- /dev/null +++ b/.agents/knowledge/tooling/format-lint.md @@ -0,0 +1,48 @@ +--- +id: tooling.format-lint +summary: Prettier pragma, markdownlint scope, and ESLint and Flow gaps for react-native-macos files, including agent knowledge files. +kind: rules +status: active +load_when: Formatting or linting a file, or writing Markdown with frontmatter +sources: + - .prettierrc.js#requirePragma + - .markdownlint-cli2.mjs#single-title + - package.json#format-check + - .eslintignore:17 + - .flowconfig:17-18 +verified: 2026-10-28 +rules: [pragma-placement, lint-markdown-explicitly, no-title-frontmatter] +related: [tooling.commands] +--- + + + +# Formatting and linting + +Use when: you format or lint files, or write Markdown that has frontmatter. Not for: choosing test commands; see [tooling.commands](commands.md). + +## Rules + +- **tooling.format-lint.pragma-placement:** Prettier only formats files with a pragma (`requirePragma: true`). Put `` in Markdown on the first line after any frontmatter, `# @format` at the top of YAML, and `@format` in the header docblock of JS and TS. Leave the pragma out of `AGENTS.md`, because upstream's copy has none. +- **tooling.format-lint.lint-markdown-explicitly:** Run markdownlint on fork Markdown with `--no-globs` and explicit paths. The shared config only globs `__docs__` folders. +- **tooling.format-lint.no-title-frontmatter:** Don't put a `title` key in Markdown frontmatter that also has an H1. markdownlint counts it as a second title (MD025). + +## Facts + +- `yarn format-check` runs Prettier on `**/*.{js,md,yml,ts,tsx}` in CI. It skips `.mts`, `.mjs`, and `.yaml` files, so check those explicitly. +- `__docs__` Markdown has its own Prettier override: no pragma required, and prose wraps. +- ESLint and Flow both ignore `*.macos.js` files. Put logic that should be type-checked in shared files behind `Platform.OS === 'macos'`. +- Native code uses clang-format, but `yarn clang-format` rewrites every native file in the repository. Run clang-format on only the files you changed. + +## Pitfalls + +- A pragma above the frontmatter breaks frontmatter parsing; markdownlint and skill hosts then see no metadata. +- Prettier pads Markdown table columns to equal widths, so one edited cell can rewrite the whole table. Run it before you commit so review diffs show the real change. + +## Verify + +```sh +yarn exec prettier --check path/to/file.md +yarn markdownlint-cli2 --no-globs path/to/file.md +yarn eslint path/to/file.js +``` diff --git a/.agents/knowledge/tools/knowledge.mts b/.agents/knowledge/tools/knowledge.mts new file mode 100644 index 000000000000..e0904607abc7 --- /dev/null +++ b/.agents/knowledge/tools/knowledge.mts @@ -0,0 +1,1703 @@ +#!/usr/bin/env node +/** + * Validates and generates the react-native-macos agent knowledge system. + * Conventions: .agents/skills/rnmacos-organize-knowledge/references/conventions.md + * + * Usage: node .agents/knowledge/tools/knowledge.mts [options] + * find Search knowledge homes, including dot folders. + * preview [--base ] Show planned knowledge changes. Never writes. + * index [--write] Check or regenerate index tables and path pointers. + * check [--ci] [--json] Run knowledge checks KC-01 to KC-14. + * report [--stale] [--budget] [--similar] [--online] + * + * @format + */ + +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parseArgs} from 'node:util'; +import {YAML} from 'zx'; + +export const PATHS = { + knowledge: '.agents/knowledge', + taxonomy: '.agents/knowledge/taxonomy.yaml', + index: '.agents/knowledge/index.md', + agents: 'AGENTS.md', + copilot: '.github/copilot-instructions.md', + instructions: '.github/instructions', + claude: 'CLAUDE.md', +}; + +const PRAGMA = ''; +const FORK_OPEN = ''; +const FORK_CLOSE = ''; +const FORK_LINE = ''; +const TOPIC_REQUIRED = [ + 'id', + 'summary', + 'kind', + 'status', + 'load_when', + 'sources', + 'verified', +]; +const TOPIC_OPTIONAL = [ + 'branch_scope', + 'requires', + 'related', + 'rules', + 'watch', + 'asserts', +]; +const YARN_BUILTINS = new Set( + 'add bin cache config constraints dedupe dlx exec explain info init install link node npm pack patch plugin rebuild remove run search set stage unlink unplug up version why workspace workspaces'.split( + ' ', + ), +); +const ID_RE = /^([a-z0-9]+)\.([a-z0-9]+(?:-[a-z0-9]+)*)$/; +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const RULE_BULLET_RE = + /^\s*[-*]\s+\*\*([a-z0-9]+\.[a-z0-9-]+\.[a-z0-9-]+):\*\*/; + +export type Severity = 'error' | 'warning' | 'advice'; +export type Issue = { + check: string; + severity: Severity; + file: string; + line?: number; + message: string; +}; +type Budget = {lines?: number; bytes?: number}; +type Route = { + name: string; + description: string; + applyTo: string[]; + topics: string[]; +}; +export type Taxonomy = { + version: number; + audited: string; + kinds: string[]; + statuses: string[]; + domains: Record; + skills: { + root: string; + links?: string[]; + prefix: string; + frontmatter: string[]; + }; + budgets: Record; + skill_budgets?: Record; + checks?: Record; + freshness: {verified_days: number; draft_days: number; audit_days: number}; + ownership: {upstream_owned: string[]}; + homes: string[]; + routes: Route[]; + redirects: Record; + retired_ids: string[]; + yarn_allow?: string[]; + absent_paths?: string[]; +}; +type Role = + | 'agents' + | 'pointer' + | 'index' + | 'topic' + | 'skill' + | 'reference' + | 'asset' + | 'instructions'; +export type Doc = { + file: string; + role: Role; + text: string; + fm: Record | null; + fmError?: string; + body: string; + bodyLine: number; +}; +export type Repo = { + root: string; + ci: boolean; + files: string[]; + fileSet: Set; + taxonomy: Taxonomy; + docs: Doc[]; +}; + +// --------------------------------------------------------------------------- +// Loading + +function git(root: string, args: string[]): string { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 512 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +// CI checks tracked files only; local runs also see untracked, non-ignored files. +export function listFiles(root: string, ci: boolean): string[] { + const args = ci + ? ['ls-files', '-z'] + : ['ls-files', '-z', '--cached', '--others', '--exclude-standard']; + const files = git(root, args).split('\0').filter(Boolean); + // lstat keeps dangling symlinks so the skill link check can report them. + return [...new Set(files)] + .filter(f => fs.lstatSync(path.join(root, f), {throwIfNoEntry: false})) + .sort(); +} + +export function parseDoc(file: string, role: Role, text: string): Doc { + const lines = text.split('\n'); + if (lines[0] !== '---') { + return {file, role, text, fm: null, body: text, bodyLine: 1}; + } + const end = lines.indexOf('---', 1); + if (end === -1) { + return { + file, + role, + text, + fm: null, + fmError: 'unterminated frontmatter', + body: text, + bodyLine: 1, + }; + } + let fm: Record | null = null; + let fmError: string | undefined; + try { + fm = YAML.parse(lines.slice(1, end).join('\n')) ?? {}; + } catch (e) { + fmError = (e as Error).message.split('\n')[0]; + } + return { + file, + role, + text, + fm, + fmError, + body: lines.slice(end + 1).join('\n'), + bodyLine: end + 2, + }; +} + +function classify(file: string, tax: Taxonomy): Role | null { + const k = PATHS.knowledge + '/'; + const skills = tax.skills.root + '/' + tax.skills.prefix; + if (file === PATHS.agents) return 'agents'; + if (file === PATHS.copilot) return 'pointer'; + if (file === PATHS.index) return 'index'; + if (file.startsWith(k) && /^[a-z0-9]+\/[^/]+\.md$/.test(file.slice(k.length))) + return 'topic'; + if ( + file.startsWith(PATHS.instructions + '/' + tax.skills.prefix) && + file.endsWith('.instructions.md') + ) + return 'instructions'; + if (file.startsWith(skills) && file.endsWith('.md')) { + const rest = file.slice(tax.skills.root.length + 1).split('/'); + if (rest.length === 2 && rest[1] === 'SKILL.md') return 'skill'; + if (rest[1] === 'assets') return 'asset'; + return 'reference'; + } + return null; +} + +export function loadRepo(root: string, opts: {ci?: boolean} = {}): Repo { + const ci = Boolean(opts.ci); + const files = listFiles(root, ci); + const taxonomy = YAML.parse( + fs.readFileSync(path.join(root, PATHS.taxonomy), 'utf8'), + ) as Taxonomy; + taxonomy.routes ??= []; + taxonomy.redirects ??= {}; + taxonomy.retired_ids ??= []; + const docs: Doc[] = []; + for (const file of files) { + const role = classify(file, taxonomy); + if (role) { + docs.push( + parseDoc(file, role, fs.readFileSync(path.join(root, file), 'utf8')), + ); + } + } + return {root, ci, files, fileSet: new Set(files), taxonomy, docs}; +} + +const topicsOf = (repo: Repo) => + repo.docs.filter( + d => d.role === 'topic' && d.fm && typeof d.fm.id === 'string', + ); +const skillsOf = (repo: Repo) => repo.docs.filter(d => d.role === 'skill'); +const skillName = (d: Doc) => d.file.split('/').at(-2) as string; + +// --------------------------------------------------------------------------- +// Markdown helpers + +// Blanks fenced code so line numbers stay stable. +export function proseLines(body: string): string[] { + const out: string[] = []; + let fence: string | null = null; + for (const line of body.split('\n')) { + const m = /^\s*(`{3,}|~{3,})/.exec(line); + if (fence) { + out.push(''); + if (m && m[1][0] === fence[0] && m[1].length >= fence.length) + fence = null; + continue; + } + if (m) { + fence = m[1]; + out.push(''); + continue; + } + out.push(line); + } + return out; +} + +const stripInlineCode = (line: string) => line.replace(/(`+)[\s\S]*?\1/g, ''); + +function codeSnippets(body: string): {text: string; line: number}[] { + const out: {text: string; line: number}[] = []; + const prose = proseLines(body); + body.split('\n').forEach((line, i) => { + if (prose[i] === '' && line !== '') { + out.push({text: line, line: i}); + return; + } + for (const m of line.matchAll(/(`+)([\s\S]*?)\1/g)) { + out.push({text: m[2], line: i}); + } + }); + return out; +} + +export function slugify(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/<[^>]+>/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .replace(/\s/g, '-'); +} + +function headings(body: string): {level: number; text: string; line: number}[] { + const out: {level: number; text: string; line: number}[] = []; + proseLines(body).forEach((line, i) => { + const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (m) out.push({level: m[1].length, text: m[2], line: i}); + }); + return out; +} + +export function anchorsOf(body: string): Set { + const seen = new Map(); + const out = new Set(); + for (const h of headings(body)) { + const base = slugify(h.text); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + out.add(n === 0 ? base : `${base}-${n}`); + } + return out; +} + +const LINK_RE = + /!?\[(?:[^\]\\]|\\.)*\]\(\s*]+)>?(?:\s+["'][^)]*["'])?\s*\)/g; + +function linksOf(body: string): {target: string; line: number}[] { + const out: {target: string; line: number}[] = []; + proseLines(body).forEach((line, i) => { + for (const m of stripInlineCode(line).matchAll(LINK_RE)) { + out.push({target: m[1], line: i}); + } + }); + return out; +} + +const isExternal = (target: string) => /^[a-z][a-z0-9+.-]*:/i.test(target); + +function measure(text: string): {lines: number; bytes: number} { + const lines = text === '' ? 0 : text.replace(/\n$/, '').split('\n').length; + return {lines, bytes: Buffer.byteLength(text)}; +} + +function forkBlock(text: string): string | null { + const start = text.indexOf(FORK_OPEN); + const end = text.indexOf(FORK_CLOSE); + return start === -1 || end < start + ? null + : text.slice(start, end + FORK_CLOSE.length); +} + +export function globToRegExp(glob: string): RegExp { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*' && glob[i + 1] === '*') { + const slash = glob[i + 2] === '/'; + re += slash ? '(?:.*/)?' : '.*'; + i += slash ? 2 : 1; + } else if (c === '*') re += '[^/]*'; + else if (c === '?') re += '[^/]'; + else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +const docLine = (d: Doc, bodyIndex: number) => d.bodyLine + bodyIndex; + +// --------------------------------------------------------------------------- +// Generation + +const esc = (s: unknown) => + String(s ?? '') + .replace(/\s+/g, ' ') + .replace(/\|/g, '\\|') + .trim(); + +const rel = (from: string, to: string) => + path.posix.relative(path.posix.dirname(from), to); + +function firstSentence(text: string): string { + const m = /^(.+?[.!?])(\s|$)/.exec(text.trim()); + return m ? m[1] : text.trim(); +} + +function generatedSections(repo: Repo): Record { + const topics = topicsOf(repo) + .filter(t => t.fm!.status !== 'deprecated') + .sort((a, b) => String(a.fm!.id).localeCompare(String(b.fm!.id))); + const topicRows = [ + '| Topic | Load when | Summary |', + '| --- | --- | --- |', + ...topics.map(t => { + const draft = t.fm!.status === 'draft' ? ' (draft)' : ''; + return `| [${t.fm!.id}](${rel(PATHS.index, t.file)})${draft} | ${esc(t.fm!.load_when)} | ${esc(t.fm!.summary)} |`; + }), + ]; + const skills = skillsOf(repo).sort((a, b) => + skillName(a).localeCompare(skillName(b)), + ); + const skillRows = skills.length + ? [ + '| Skill | Use when |', + '| --- | --- |', + ...skills.map( + s => + `| [\`${skillName(s)}\`](${rel(PATHS.index, s.file)}) | ${esc(firstSentence(String(s.fm?.description ?? '')))} |`, + ), + ] + : ['No skills yet.']; + const routes = repo.taxonomy.routes; + const routeRows = routes.length + ? [ + '| Pointer | Applies to | Topics |', + '| --- | --- | --- |', + ...routes.map( + r => + `| [${r.name}](${rel(PATHS.index, pointerPath(repo, r))}) | ${esc(r.applyTo.map(g => `\`${g}\``).join(', '))} | ${esc(r.topics.join(', '))} |`, + ), + ] + : ['No path routes yet.']; + return { + topics: topicRows.join('\n'), + skills: skillRows.join('\n'), + routes: routeRows.join('\n'), + }; +} + +const pointerPath = (repo: Repo, r: Route) => + `${PATHS.instructions}/${repo.taxonomy.skills.prefix}${r.name}.instructions.md`; + +function renderPointer(repo: Repo, r: Route): string { + const file = pointerPath(repo, r); + const byId = new Map(topicsOf(repo).map(t => [t.fm!.id, t.file])); + const links = r.topics.map(id => { + const target = + byId.get(id) ?? `${PATHS.knowledge}/${id.replace('.', '/')}.md`; + return `[${id}](${rel(file, target)})`; + }); + return [ + '---', + `applyTo: '${r.applyTo.join(',')}'`, + '---', + '', + `# ${r.description}`, + '', + ``, + '', + `Before you change these files, read ${links.join(', ')}.`, + '', + ].join('\n'); +} + +export function generate(repo: Repo): Map { + const out = new Map(); + const indexPath = path.join(repo.root, PATHS.index); + if (fs.existsSync(indexPath)) { + let text = fs.readFileSync(indexPath, 'utf8'); + for (const [name, content] of Object.entries(generatedSections(repo))) { + const re = new RegExp( + `()[\\s\\S]*?()`, + ); + text = text.replace(re, (_m, a, b) => `${a}\n\n${content}\n\n${b}`); + } + out.set(PATHS.index, text); + } + for (const r of repo.taxonomy.routes) + out.set(pointerPath(repo, r), renderPointer(repo, r)); + return out; +} + +export function drift(repo: Repo): {file: string; reason: string}[] { + const out: {file: string; reason: string}[] = []; + const indexPath = path.join(repo.root, PATHS.index); + if (fs.existsSync(indexPath)) { + const text = fs.readFileSync(indexPath, 'utf8'); + for (const name of ['topics', 'skills', 'routes']) { + const begin = text.split(``).length - 1; + const end = text.split(``).length - 1; + if (begin !== 1 || end !== 1) + out.push({ + file: PATHS.index, + reason: `missing or repeated ${name} markers`, + }); + } + } + const expected = generate(repo); + for (const [file, text] of expected) { + const p = path.join(repo.root, file); + if (!fs.existsSync(p)) out.push({file, reason: 'missing'}); + else if (fs.readFileSync(p, 'utf8') !== text) + out.push({file, reason: 'stale'}); + } + for (const d of repo.docs) { + if (d.role === 'instructions' && !expected.has(d.file)) { + out.push({file: d.file, reason: 'no matching route'}); + } + } + return out; +} + +export function writeGenerated(repo: Repo): string[] { + const written: string[] = []; + for (const [file, text] of generate(repo)) { + const p = path.join(repo.root, file); + if (!fs.existsSync(p) || fs.readFileSync(p, 'utf8') !== text) { + fs.mkdirSync(path.dirname(p), {recursive: true}); + fs.writeFileSync(p, text); + written.push(file); + } + } + for (const {file, reason} of drift(repo)) { + if (reason === 'no matching route') { + fs.rmSync(path.join(repo.root, file)); + written.push(`${file} (removed)`); + } + } + return written; +} + +// --------------------------------------------------------------------------- +// Checks + +export function runChecks(repo: Repo): Issue[] { + const issues: Issue[] = []; + const sev = (check: string, fallback: Severity = 'error'): Severity => + repo.taxonomy.checks?.[check] ?? fallback; + const add = ( + check: string, + file: string, + message: string, + line?: number, + severity: Severity = sev(check), + ) => issues.push({check, severity, file, line, message}); + const tax = repo.taxonomy; + const topics = topicsOf(repo); + const ids = new Map(); + const fileExists = (f: string) => + repo.fileSet.has(f) || + repo.files.some(x => x.startsWith(f.replace(/\/$/, '') + '/')); + + // KC-01 structure and frontmatter + for (const d of repo.docs) { + if (d.fmError) add('KC-01', d.file, `invalid frontmatter: ${d.fmError}`, 1); + if (d.fm && 'title' in d.fm) { + add( + 'KC-01', + d.file, + 'frontmatter must not contain `title` (MD025 single-title)', + 1, + ); + } + const wantsFm = ['topic', 'skill', 'instructions', 'asset'].includes( + d.role, + ); + if (d.fm && !wantsFm) + add('KC-01', d.file, 'frontmatter is not allowed in this file', 1); + if ( + !d.fm && + !d.fmError && + ['topic', 'skill', 'instructions'].includes(d.role) + ) { + add('KC-01', d.file, 'frontmatter must start on line 1', 1); + } + const firstBody = d.body.split('\n').findIndex(l => l.trim() !== ''); + const hasPragma = d.body.includes(PRAGMA) || d.text.includes('@format'); + if (['agents', 'index', 'instructions'].includes(d.role)) { + if (hasPragma) + add('KC-01', d.file, 'must not contain a Prettier @format pragma'); + } else if (d.body.split('\n')[firstBody]?.trim() !== PRAGMA) { + add( + 'KC-01', + d.file, + `first line after any frontmatter must be ${PRAGMA}`, + docLine(d, Math.max(firstBody, 0)), + ); + } + const h1 = headings(d.body).filter(h => h.level === 1); + if (h1.length !== 1) + add('KC-01', d.file, `expected exactly one H1, found ${h1.length}`); + if (d.role !== 'topic' || !d.fm) continue; + for (const key of TOPIC_REQUIRED) { + if (d.fm[key] === undefined) + add('KC-01', d.file, `missing required frontmatter key \`${key}\``, 1); + } + for (const key of Object.keys(d.fm)) { + if ( + !TOPIC_REQUIRED.includes(key) && + !TOPIC_OPTIONAL.includes(key) && + key !== 'title' + ) { + add('KC-01', d.file, `unknown frontmatter key \`${key}\``, 1); + } + } + if (d.fm.kind && !tax.kinds.includes(d.fm.kind)) + add('KC-01', d.file, `kind must be one of ${tax.kinds.join(', ')}`, 1); + if (d.fm.status && !tax.statuses.includes(d.fm.status)) + add( + 'KC-01', + d.file, + `status must be one of ${tax.statuses.join(', ')}`, + 1, + ); + if (typeof d.fm.summary === 'string' && d.fm.summary.length > 160) + add('KC-01', d.file, 'summary exceeds 160 characters', 1); + if (typeof d.fm.load_when === 'string' && d.fm.load_when.length > 120) + add('KC-01', d.file, 'load_when exceeds 120 characters', 1); + if (!Array.isArray(d.fm.sources) || d.fm.sources.length === 0) + add('KC-01', d.file, '`sources` must be a non-empty list', 1); + if (d.fm.verified !== undefined && !DATE_RE.test(String(d.fm.verified))) + add('KC-01', d.file, '`verified` must be YYYY-MM-DD', 1); + for (const key of [ + 'branch_scope', + 'requires', + 'related', + 'rules', + 'watch', + 'asserts', + ]) { + if (d.fm[key] !== undefined && !Array.isArray(d.fm[key])) + add('KC-01', d.file, `\`${key}\` must be a list`, 1); + } + const lines = d.body.split('\n'); + const h1Line = h1[0]?.line ?? -1; + const next = lines.slice(h1Line + 1).find(l => l.trim() !== ''); + if (h1Line >= 0 && !next?.startsWith('Use when:')) { + add( + 'KC-01', + d.file, + 'the first paragraph after the H1 must start with "Use when:"', + docLine(d, h1Line), + ); + } + } + + // KC-02 identity + const ruleIds = new Map(); + for (const t of topics) { + const id = String(t.fm!.id); + const m = ID_RE.exec(id); + if (!m) + add('KC-02', t.file, `id \`${id}\` must look like domain.topic-name`, 1); + else if (!(m[1] in tax.domains)) + add( + 'KC-02', + t.file, + `domain \`${m[1]}\` is not registered in taxonomy.yaml`, + 1, + ); + else if (t.file !== `${PATHS.knowledge}/${m[1]}/${m[2]}.md`) { + add( + 'KC-02', + t.file, + `id \`${id}\` must match the file path ${PATHS.knowledge}/${m[1]}/${m[2]}.md`, + 1, + ); + } + if (ids.has(id)) + add( + 'KC-02', + t.file, + `duplicate id \`${id}\` (also ${ids.get(id)!.file})`, + 1, + ); + ids.set(id, t); + if (tax.retired_ids.includes(id)) + add('KC-02', t.file, `id \`${id}\` is retired and must not be reused`, 1); + if (id in tax.redirects) + add('KC-02', t.file, `id \`${id}\` is also a redirect source`, 1); + const declared = new Set(); + for (const slug of (Array.isArray(t.fm!.rules) + ? t.fm!.rules + : []) as string[]) { + if (!SLUG_RE.test(String(slug))) + add('KC-02', t.file, `rule slug \`${slug}\` must be kebab-case`, 1); + declared.add(`${id}.${slug}`); + } + const found = new Map(); + proseLines(t.body).forEach((line, i) => { + const r = RULE_BULLET_RE.exec(line); + if (!r) return; + if (found.has(r[1])) + add('KC-02', t.file, `rule \`${r[1]}\` appears twice`, docLine(t, i)); + found.set(r[1], i); + if (!declared.has(r[1])) + add( + 'KC-02', + t.file, + `rule \`${r[1]}\` is not declared in frontmatter \`rules\``, + docLine(t, i), + ); + const owner = ruleIds.get(r[1]); + if (owner && owner !== t.file) + add( + 'KC-02', + t.file, + `rule \`${r[1]}\` is also defined in ${owner}`, + docLine(t, i), + ); + ruleIds.set(r[1], t.file); + }); + for (const r of declared) + if (!found.has(r)) + add('KC-02', t.file, `declared rule \`${r}\` has no bullet`, 1); + } + for (const [from, to] of Object.entries(tax.redirects)) { + if (!ids.has(to)) + add( + 'KC-02', + PATHS.taxonomy, + `redirect ${from} -> ${to} targets an unknown id`, + ); + } + for (const t of topics) { + for (const key of ['requires', 'related']) { + for (const ref of (Array.isArray(t.fm![key]) + ? t.fm![key] + : []) as string[]) { + if (ids.has(ref)) continue; + if (ref in tax.redirects) + add( + 'KC-02', + t.file, + `\`${key}\` uses redirected id \`${ref}\`; use \`${tax.redirects[ref]}\``, + 1, + ); + else + add( + 'KC-02', + t.file, + `\`${key}\` references unknown id \`${ref}\``, + 1, + ); + } + } + } + const agentsDoc = repo.docs.find(d => d.role === 'agents'); + if (agentsDoc) { + const seen = new Set(); + proseLines(agentsDoc.body).forEach((line, i) => { + const m = /^\s*[-*]\s+\*\*(I\d+)\b/.exec(line); + if (!m) return; + if (seen.has(m[1])) + add( + 'KC-02', + agentsDoc.file, + `fork rule ${m[1]} is duplicated`, + docLine(agentsDoc, i), + ); + seen.add(m[1]); + }); + } + + // KC-03 generated files + for (const {file, reason} of drift(repo)) { + add( + 'KC-03', + file, + `generated content is ${reason}; run knowledge.mts index --write`, + ); + } + + // KC-04 links and anchors + for (const d of repo.docs) { + if (d.role === 'asset') continue; + for (const {target, line} of linksOf(d.body)) { + if (isExternal(target)) continue; + const [rawPath, anchor] = target.split('#'); + const targetFile = rawPath + ? path.posix.normalize( + path.posix.join( + path.posix.dirname(d.file), + decodeURIComponent(rawPath), + ), + ) + : d.file; + if (targetFile.startsWith('..')) { + add( + 'KC-04', + d.file, + `link \`${target}\` leaves the repository`, + docLine(d, line), + ); + continue; + } + if (!fileExists(targetFile)) { + add('KC-04', d.file, `broken link \`${target}\``, docLine(d, line)); + continue; + } + if ( + anchor && + targetFile.endsWith('.md') && + repo.fileSet.has(targetFile) + ) { + const text = fs.readFileSync(path.join(repo.root, targetFile), 'utf8'); + const body = parseDoc(targetFile, 'reference', text).body; + if (!anchorsOf(body).has(anchor)) + add( + 'KC-04', + d.file, + `missing anchor \`#${anchor}\` in ${targetFile}`, + docLine(d, line), + ); + } + } + } + + // KC-05 cited paths exist + const topLevel = new Set(repo.files.map(f => f.split('/')[0])); + const absent = new Set(tax.absent_paths ?? []); + for (const t of topics) { + for (const src of (Array.isArray(t.fm!.sources) + ? t.fm!.sources + : []) as string[]) { + const s = String(src); + if (isExternal(s)) continue; + const m = + /^([^#:@\s]+)(?::(\d+)(?:-(\d+))?)?(?:@[0-9a-f]+)?(?:#(.+))?$/.exec(s); + if (!m) { + add('KC-05', t.file, `cannot parse source \`${s}\``, 1); + continue; + } + if (!fileExists(m[1])) { + add('KC-05', t.file, `source path \`${m[1]}\` does not exist`, 1); + continue; + } + if (!repo.fileSet.has(m[1])) continue; + const content = fs.readFileSync(path.join(repo.root, m[1]), 'utf8'); + const last = Number(m[3] ?? m[2] ?? 0); + if (last && last > measure(content).lines) + add('KC-05', t.file, `source \`${s}\` is past the end of the file`, 1); + if (m[4] && !content.includes(m[4])) + add('KC-05', t.file, `symbol \`${m[4]}\` not found in ${m[1]}`, 1); + } + } + for (const d of repo.docs) { + if (d.role === 'asset') continue; + proseLines(d.body).forEach((line, i) => { + for (const m of line.matchAll(/`([^`\s]+)`/g)) { + const token = m[1].replace(/:\d+(-\d+)?$/, '').replace(/#.*$/, ''); + if (!token.includes('/') || /[*?{}<>$]|\.\.\.|^\.?\.\//.test(token)) + continue; + if (!topLevel.has(token.split('/')[0])) continue; + if (absent.has(token.replace(/\/$/, ''))) continue; + if (!fileExists(token)) + add( + 'KC-05', + d.file, + `path \`${token}\` does not exist`, + docLine(d, i), + ); + } + }); + } + + // KC-06 yarn commands resolve + const scripts = readJson(repo.root, 'package.json')?.scripts ?? {}; + const bins = rootBins(repo.root); + const allowed = new Set(tax.yarn_allow ?? []); + for (const d of repo.docs) { + if (d.role === 'asset') continue; + for (const {text, line} of codeSnippets(d.body)) { + for (const m of text.matchAll( + /(?:^|[\s;&|(])yarn\s+(?:--?[\w-]+(?:[ =]\S+)?\s+)*([a-z@][\w:./@-]*)/g, + )) { + const cmd = m[1]; + const hasArgs = /^[ \t]+[^\s;&|]/.test( + text.slice((m.index ?? 0) + m[0].length), + ); + const script = String(scripts[cmd] ?? '').trim(); + if (hasArgs && bins.has(cmd) && script && script !== cmd) { + add( + 'KC-06', + d.file, + `\`yarn ${cmd}\` runs the root script (\`${script.slice(0, 60)}\`), not the ${cmd} binary; use \`yarn exec ${cmd}\``, + docLine(d, line), + ); + continue; + } + if ( + cmd in scripts || + YARN_BUILTINS.has(cmd) || + bins.has(cmd) || + allowed.has(cmd) + ) + continue; + add( + 'KC-06', + d.file, + `\`yarn ${cmd}\` is not a root script, root dependency binary, or Yarn built-in`, + docLine(d, line), + ); + } + } + } + + // KC-07 budgets + for (const b of budgetRows(repo)) { + if (b.ratio > 1) + add('KC-07', b.file, `${b.label} over budget: ${b.detail}`); + else if (b.ratio >= (tax.budgets.warn_ratio ?? 0.8)) + add( + 'KC-07', + b.file, + `${b.label} near budget: ${b.detail}`, + undefined, + 'warning', + ); + } + + // KC-08 skills + const agentsText = agentsDoc?.text ?? ''; + for (const s of skillsOf(repo)) { + const name = skillName(s); + const fm = s.fm ?? {}; + if (fm.name !== name) + add( + 'KC-08', + s.file, + `frontmatter name \`${fm.name}\` must equal folder name \`${name}\``, + 1, + ); + if ( + !new RegExp(`^${tax.skills.prefix}[a-z0-9]+(?:-[a-z0-9]+)*$`).test( + name, + ) || + name.length > 64 + ) { + add( + 'KC-08', + s.file, + `skill name must match ${tax.skills.prefix}-, lowercase, at most 64 characters`, + 1, + ); + } + const desc = typeof fm.description === 'string' ? fm.description : ''; + const dLimit = tax.budgets.skill_description ?? {warn: 400, max: 1024}; + if (!desc) add('KC-08', s.file, 'description is required', 1); + if (desc.length > dLimit.max) + add('KC-08', s.file, `description exceeds ${dLimit.max} characters`, 1); + else if (desc.length > dLimit.warn) + add( + 'KC-08', + s.file, + `description exceeds ${dLimit.warn} characters`, + 1, + 'warning', + ); + if (/[<>]/.test(desc)) + add('KC-08', s.file, 'description must not contain angle brackets', 1); + for (const key of Object.keys(fm)) { + if (!tax.skills.frontmatter.includes(key)) + add('KC-08', s.file, `frontmatter key \`${key}\` is not allowed`, 1); + } + if ( + /\b(bash|shell|powershell|sh)\b/i.test( + JSON.stringify(fm['allowed-tools'] ?? ''), + ) + ) { + add( + 'KC-08', + s.file, + '`allowed-tools` must not pre-approve shell access', + 1, + ); + } + const dir = path.posix.dirname(s.file); + const linked = new Set( + linksOf(s.body).map(l => + path.posix.normalize(path.posix.join(dir, l.target.split('#')[0])), + ), + ); + for (const f of repo.files.filter(f => f.startsWith(dir + '/'))) { + const parts = f.slice(dir.length + 1).split('/'); + if (parts.length > 2) + add( + 'KC-08', + f, + 'skill files must be at most one level below the skill folder', + ); + if (parts[0] === 'references' && parts.length === 2 && !linked.has(f)) { + add( + 'KC-08', + f, + 'reference is not linked from SKILL.md', + undefined, + 'warning', + ); + } + } + if (!agentsText.includes(`(${s.file})`)) + add('KC-08', s.file, `skill is not listed in ${PATHS.agents}`); + for (const dir of tax.skills.links ?? []) { + const link = `${dir}/${name}`; + const target = path.posix.relative(dir, path.posix.dirname(s.file)); + let actual: string | null = null; + try { + actual = fs.readlinkSync(path.join(repo.root, link)); + } catch {} + if (!repo.fileSet.has(link) || actual !== target) + add('KC-08', s.file, `needs a symlink ${link} -> ${target}`); + } + } + const skillNames = new Set(skillsOf(repo).map(skillName)); + for (const dir of tax.skills.links ?? []) { + for (const f of repo.files) { + if (!f.startsWith(`${dir}/${tax.skills.prefix}`)) continue; + const rest = f.slice(dir.length + 1); + if (rest.includes('/')) + add( + 'KC-08', + f, + `${dir} holds only symlinks; put skill files in ${tax.skills.root}`, + ); + else if (!skillNames.has(rest)) + add( + 'KC-08', + f, + `symlink does not point to a skill in ${tax.skills.root}`, + ); + } + } + + // KC-09 pointers + const pointer = repo.docs.find(d => d.role === 'pointer'); + if (pointer) { + const links = linksOf(pointer.body); + const prose = pointer.body + .split('\n') + .filter(l => l.trim() && l.trim() !== PRAGMA && !l.startsWith('#')) + .join(' '); + if ( + links.length !== 1 || + path.posix.normalize(path.posix.join('.github', links[0].target)) !== + PATHS.agents + ) { + add( + 'KC-09', + pointer.file, + `must contain exactly one link, to ${PATHS.agents}`, + ); + } + if (prose.length > 300) + add( + 'KC-09', + pointer.file, + 'pointer prose must stay under 300 characters', + ); + } + for (const d of repo.docs.filter(x => x.role === 'instructions')) { + const applyTo = String(d.fm?.applyTo ?? ''); + if (!applyTo) add('KC-09', d.file, '`applyTo` is required', 1); + for (const g of applyTo + .split(',') + .map(x => x.trim()) + .filter(Boolean)) { + if (/[{}]/.test(g)) + add('KC-09', d.file, `applyTo glob \`${g}\` must not use braces`, 1); + const re = globToRegExp(g); + if (!repo.files.some(f => re.test(f))) + add('KC-09', d.file, `applyTo glob \`${g}\` matches no files`, 1); + } + } + + // KC-10 no @ imports + for (const d of repo.docs) { + proseLines(d.body).forEach((line, i) => { + if ( + /(^|\s)@(?:\.{0,2}\/)?[\w.-]+(?:\/[\w.-]+)*\.(md|mdc|txt|ya?ml)\b/.test( + stripInlineCode(line), + ) + ) { + add( + 'KC-10', + d.file, + '`@path` imports load files into every session; use a Markdown link', + docLine(d, i), + ); + } + }); + } + + // KC-11a exact duplicate bullets + const bullets = new Map(); + for (const d of repo.docs) { + if (d.role === 'asset' || d.role === 'index' || d.role === 'instructions') + continue; + proseLines(d.body).forEach((line, i) => { + const norm = normalizeBullet(line); + if (!norm || norm.length < 40) return; + const prev = bullets.get(norm); + if (prev) + add( + 'KC-11a', + d.file, + `duplicate bullet; also at ${prev.file}:${prev.line}`, + docLine(d, i), + ); + else bullets.set(norm, {file: d.file, line: docLine(d, i)}); + }); + } + + // KC-12 merge safety + if (agentsDoc) { + const text = agentsDoc.text; + const opens = text.split(FORK_OPEN).length - 1; + const closes = text.split(FORK_CLOSE).length - 1; + if (opens !== 1 || closes !== 1 || !forkBlock(text)) { + add( + 'KC-12', + agentsDoc.file, + `expected one ${FORK_OPEN} ... ${FORK_CLOSE} block, found ${opens} openers and ${closes} closers`, + ); + } + if (!text.startsWith('# React Native\n')) + add( + 'KC-12', + agentsDoc.file, + "first line must stay upstream's `# React Native` heading", + 1, + ); + } else { + add('KC-12', PATHS.agents, 'AGENTS.md is missing'); + } + const claude = path.join(repo.root, PATHS.claude); + if ( + fs.existsSync(claude) && + fs.readFileSync(claude, 'utf8') !== '@AGENTS.md\n' + ) { + add( + 'KC-12', + PATHS.claude, + 'CLAUDE.md, if present, must be exactly "@AGENTS.md" plus a newline', + ); + } + const owned = tax.ownership.upstream_owned.map(globToRegExp); + for (const f of repo.files) { + if (!owned.some(re => re.test(f)) || !/\.(md|mjs|js|ya?ml)$/.test(f)) + continue; + const text = fs.readFileSync(path.join(repo.root, f), 'utf8'); + if (/\[macOS|macOS\]|rnmacos-|\.agents\/knowledge/.test(text)) + add('KC-12', f, 'upstream-owned file contains fork content'); + } + + // KC-13 reachability + if (agentsDoc && !agentsDoc.text.includes(`(${PATHS.index})`)) { + add('KC-13', agentsDoc.file, `must link to ${PATHS.index}`); + } + for (const r of tax.routes) { + if (!SLUG_RE.test(r.name)) + add( + 'KC-13', + PATHS.taxonomy, + `route name \`${r.name}\` must be kebab-case`, + ); + for (const id of r.topics) { + if (ids.get(id)?.fm?.status !== 'active') + add( + 'KC-13', + PATHS.taxonomy, + `route ${r.name} targets \`${id}\`, which is not an active topic`, + ); + } + } + for (const t of topics) { + for (const ref of (Array.isArray(t.fm!.requires) + ? t.fm!.requires + : []) as string[]) { + if (ids.get(ref)?.fm?.status === 'deprecated') + add('KC-13', t.file, `requires deprecated topic \`${ref}\``, 1); + } + } + + // KC-14 asserts + for (const t of topics) { + for (const a of (Array.isArray(t.fm!.asserts) + ? t.fm!.asserts + : []) as any[]) { + const msg = checkAssert(repo.root, a); + if (msg) add('KC-14', t.file, msg, 1); + } + } + + return issues; +} + +function normalizeBullet(line: string): string | null { + const m = /^\s*(?:[-*+]|\d+\.)\s+(.*)$/.exec(line); + if (!m) return null; + return m[1] + .replace(/^\*\*[^*]+:\*\*\s*/, '') + .toLowerCase() + .replace(/[*_`[\]()]/g, '') + .replace(/\s+/g, ' ') + .replace(/[.;:,]+$/, '') + .trim(); +} + +function readJson(root: string, file: string): any { + try { + return JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); + } catch { + return null; + } +} + +function rootBins(root: string): Set { + const pkg = readJson(root, 'package.json') ?? {}; + const bins = new Set(); + for (const dep of Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + })) { + const meta = readJson(root, `node_modules/${dep}/package.json`); + if (typeof meta?.bin === 'string') bins.add(dep.split('/').at(-1)!); + else if (meta?.bin) Object.keys(meta.bin).forEach(b => bins.add(b)); + } + return bins; +} + +function lookup(obj: any, key: string): unknown { + return key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj); +} + +function checkAssert(root: string, a: any): string | null { + const file = String(a?.file ?? ''); + const p = path.join(root, file); + if (!file || !fs.existsSync(p)) + return `assert file \`${file}\` does not exist`; + const text = fs.readFileSync(p, 'utf8'); + if (a.contains !== undefined) { + return text.includes(String(a.contains)) + ? null + : `assert: ${file} does not contain \`${a.contains}\``; + } + const key = a.json ?? a.yaml; + if (!key) return 'assert needs `contains`, `json`, or `yaml`'; + let value: unknown; + try { + value = lookup(a.json ? JSON.parse(text) : YAML.parse(text), String(key)); + } catch (e) { + return `assert: cannot parse ${file}: ${(e as Error).message}`; + } + if (a.equals !== undefined && value !== a.equals) + return `assert: ${file} ${key} is \`${value}\`, expected \`${a.equals}\``; + if ( + a.matches !== undefined && + !new RegExp(String(a.matches)).test(String(value)) + ) { + return `assert: ${file} ${key} is \`${value}\`, expected to match /${a.matches}/`; + } + return null; +} + +// --------------------------------------------------------------------------- +// Budgets and reports + +type BudgetRow = {file: string; label: string; ratio: number; detail: string}; + +export function budgetRows(repo: Repo): BudgetRow[] { + const b = repo.taxonomy.budgets; + const rows: BudgetRow[] = []; + const row = ( + file: string, + label: string, + text: string, + limit: Budget | undefined, + ) => { + if (!limit) return; + const m = measure(text); + const parts: string[] = []; + let ratio = 0; + if (limit.lines) { + ratio = Math.max(ratio, m.lines / limit.lines); + parts.push(`${m.lines}/${limit.lines} lines`); + } + if (limit.bytes) { + ratio = Math.max(ratio, m.bytes / limit.bytes); + parts.push(`${m.bytes}/${limit.bytes} bytes`); + } + rows.push({file, label, ratio, detail: parts.join(', ')}); + }; + let tier0 = 0; + for (const d of repo.docs) { + switch (d.role) { + case 'agents': { + row(d.file, 'AGENTS.md', d.text, b.agents_total); + const block = forkBlock(d.text); + if (block) row(d.file, 'fork block', block, b.agents_fork_block); + tier0 += measure(d.text).bytes; + break; + } + case 'pointer': + row(d.file, 'pointer', d.text, b.copilot_pointer); + tier0 += measure(d.text).bytes; + break; + case 'index': + row(d.file, 'index', d.text, b.index); + break; + case 'topic': + row(d.file, 'topic', d.text, b.topic); + break; + case 'skill': { + const own = repo.taxonomy.skill_budgets?.[skillName(d)] ?? {}; + row(d.file, 'SKILL.md', d.text, {...b.skill, ...own}); + tier0 += Buffer.byteLength( + String(d.fm?.name ?? '') + String(d.fm?.description ?? ''), + ); + break; + } + case 'reference': + row(d.file, 'reference', d.text, b.reference); + break; + case 'instructions': + row(d.file, 'path pointer', d.text, b.instructions_pointer); + break; + } + } + if (b.tier0?.bytes) { + rows.push({ + file: '(tier 0)', + label: 'always-loaded context', + ratio: tier0 / b.tier0.bytes, + detail: `${tier0}/${b.tier0.bytes} bytes`, + }); + } + return rows; +} + +const daysSince = (date: string, now: Date) => + Math.floor( + (now.getTime() - new Date(`${date}T00:00:00Z`).getTime()) / 86_400_000, + ); + +export function staleRows( + repo: Repo, + opts: {online?: boolean; now?: Date} = {}, +): string[] { + const now = opts.now ?? new Date(); + const f = repo.taxonomy.freshness; + const out: string[] = []; + const audited = daysSince(String(repo.taxonomy.audited), now); + if (audited > f.audit_days) + out.push( + `taxonomy: last audit ${audited} days ago (limit ${f.audit_days})`, + ); + for (const t of topicsOf(repo)) { + const id = t.fm!.id; + const verified = String(t.fm!.verified ?? ''); + if (!DATE_RE.test(verified)) continue; + const age = daysSince(verified, now); + if (age > f.verified_days) out.push(`${id}: verified ${age} days ago`); + if (t.fm!.status === 'draft' && age > f.draft_days) + out.push(`${id}: draft for ${age} days`); + const paths = ((t.fm!.sources ?? []) as string[]) + .map(s => String(s).split(/[#:@]/)[0]) + .filter(p => p && !isExternal(p) && repo.fileSet.has(p)); + if (paths.length) { + const changed = git(repo.root, [ + 'log', + '--format=%h %cs', + `--since=${verified}T23:59:59`, + '--', + ...paths, + ]).trim(); + if (changed) + out.push( + `${id}: sources changed since ${verified} (${changed.split('\n').length} commits)`, + ); + } + if (opts.online) { + for (const w of (t.fm!.watch ?? []) as string[]) { + const m = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(String(w)); + if (!m) continue; + try { + const state = execFileSync( + 'gh', + [ + 'pr', + 'view', + m[2], + '--repo', + m[1], + '--json', + 'state', + '-q', + '.state', + ], + {encoding: 'utf8'}, + ).trim(); + if (state !== 'OPEN') + out.push(`${id}: watched ${w} is ${state}; re-verify`); + } catch { + out.push(`${id}: could not read watched ${w}`); + } + } + } + } + return out; +} + +export function similarRows(repo: Repo, threshold = 0.6): string[] { + const items: { + file: string; + line: number; + text: string; + grams: Set; + }[] = []; + const collect = (file: string, body: string, offset: number) => { + proseLines(body).forEach((line, i) => { + const norm = normalizeBullet(line); + if (!norm || norm.length < 60) return; + const words = norm.split(' '); + const grams = new Set(); + for (let j = 0; j + 2 < words.length; j++) + grams.add(words.slice(j, j + 3).join(' ')); + items.push({file, line: offset + i, text: norm, grams}); + }); + }; + for (const d of repo.docs) { + if (!['asset', 'index', 'instructions'].includes(d.role)) + collect(d.file, d.body, d.bodyLine); + } + for (const f of repo.files.filter( + x => x.startsWith('docsite/docs/') && x.endsWith('.md'), + )) { + collect(f, fs.readFileSync(path.join(repo.root, f), 'utf8'), 1); + } + const out: string[] = []; + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const a = items[i]; + const b = items[j]; + if (a.file === b.file || !a.grams.size || !b.grams.size) continue; + let inter = 0; + for (const g of a.grams) if (b.grams.has(g)) inter++; + const score = inter / (a.grams.size + b.grams.size - inter); + if (score >= threshold) + out.push( + `${score.toFixed(2)} ${a.file}:${a.line} ~ ${b.file}:${b.line}`, + ); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// find and preview + +export function find( + repo: Repo, + terms: string[], +): {file: string; id?: string; score: number; hits: string[]}[] { + const lower = terms.map(t => t.toLowerCase()); + const homes = repo.taxonomy.homes; + const ids = new Map(topicsOf(repo).map(t => [t.file, String(t.fm!.id)])); + const results: {file: string; id?: string; score: number; hits: string[]}[] = + []; + for (const file of repo.files) { + if (!/\.(md|ya?ml)$/.test(file)) continue; + if (!homes.some(h => file === h || file.startsWith(h + '/'))) continue; + const lines = fs + .readFileSync(path.join(repo.root, file), 'utf8') + .split('\n'); + const matched = new Set(); + const hits: string[] = []; + lines.forEach((line, i) => { + const l = line.toLowerCase(); + const found = lower.filter(t => l.includes(t)); + if (!found.length) return; + found.forEach(t => matched.add(t)); + if (hits.length < 3) hits.push(`${i + 1}: ${line.trim().slice(0, 160)}`); + }); + if (matched.size) + results.push({file, id: ids.get(file), score: matched.size, hits}); + } + return results.sort( + (a, b) => b.score - a.score || a.file.localeCompare(b.file), + ); +} + +const MANAGED = [ + PATHS.agents, + PATHS.copilot, + PATHS.claude, + PATHS.instructions + '/', + '.agents/', + '.claude/', +]; + +export function preview(repo: Repo, base?: string): string { + const out: string[] = []; + const ref = base ?? defaultBase(repo.root); + const isManaged = (f: string) => + MANAGED.some(m => (m.endsWith('/') ? f.startsWith(m) : f === m)); + const changed = git(repo.root, ['diff', '--name-status', ref, '--']) + .split('\n') + .filter(Boolean) + .map(l => l.split('\t')) + .filter(p => isManaged(p.at(-1)!)) + .map(p => `${p[0]} ${p.slice(1).join(' -> ')}`); + const untracked = git(repo.root, [ + 'ls-files', + '--others', + '--exclude-standard', + ]) + .split('\n') + .filter(f => f && isManaged(f)) + .map(f => `? ${f}`); + out.push(`Base: ${ref}`, '', 'Changed knowledge files:'); + out.push( + ...[...changed, ...untracked].map(l => ` ${l}`), + ...(changed.length + untracked.length ? [] : [' (none)']), + ); + + const baseIds = new Map(); + const listing = git(repo.root, [ + 'ls-tree', + '-r', + '--name-only', + ref, + '--', + PATHS.knowledge, + ]).split('\n'); + for (const f of listing) { + if (classify(f, repo.taxonomy) !== 'topic') continue; + const doc = parseDoc(f, 'topic', git(repo.root, ['show', `${ref}:${f}`])); + if (doc.fm?.id) baseIds.set(String(doc.fm.id), f); + } + const nowIds = new Map(topicsOf(repo).map(t => [String(t.fm!.id), t.file])); + const idLines: string[] = []; + for (const [id, f] of nowIds) { + if (!baseIds.has(id)) idLines.push(` + ${id} (${f})`); + else if (baseIds.get(id) !== f) + idLines.push(` > ${id} moved ${baseIds.get(id)} -> ${f}`); + } + for (const [id, f] of baseIds) { + if (nowIds.has(id)) continue; + const fate = repo.taxonomy.redirects[id] + ? `redirects to ${repo.taxonomy.redirects[id]}` + : repo.taxonomy.retired_ids.includes(id) + ? 'retired' + : 'NEEDS a redirect or retired_ids entry'; + idLines.push(` - ${id} (${f}) ${fate}`); + } + out.push( + '', + 'Topic IDs:', + ...(idLines.length ? idLines : [' (no changes)']), + ); + + const d = drift(repo); + out.push( + '', + 'Generated files `index --write` would update:', + ...(d.length ? d.map(x => ` ${x.file} (${x.reason})`) : [' (none)']), + ); + + const touched = new Set( + [...changed, ...untracked].map(l => l.split(' ').at(-1)!), + ); + const budgets = budgetRows(repo).filter( + r => touched.has(r.file) || r.file.startsWith('('), + ); + out.push( + '', + 'Budgets for changed files:', + ...budgets.map( + r => ` ${(r.ratio * 100).toFixed(0)}% ${r.file} ${r.label}: ${r.detail}`, + ), + ); + + const issues = runChecks(repo); + const count = (s: Severity) => issues.filter(i => i.severity === s).length; + out.push( + '', + `Checks: ${count('error')} errors, ${count('warning')} warnings`, + ); + out.push( + ...issues + .filter(i => i.severity === 'error') + .slice(0, 20) + .map(i => ` ${formatIssue(i)}`), + ); + return out.join('\n'); +} + +function defaultBase(root: string): string { + try { + return git(root, ['merge-base', 'HEAD', 'origin/main']).trim(); + } catch { + return 'HEAD'; + } +} + +// --------------------------------------------------------------------------- +// CLI + +export function formatIssue(i: Issue): string { + return `${i.severity.toUpperCase()} ${i.check} ${i.file}${i.line ? `:${i.line}` : ''} ${i.message}`; +} + +function repoRoot(): string { + return git(process.cwd(), ['rev-parse', '--show-toplevel']).trim(); +} + +export function main(argv: string[]): number { + const [command, ...rest] = argv; + const {values, positionals} = parseArgs({ + args: rest, + allowPositionals: true, + options: { + ci: {type: 'boolean'}, + json: {type: 'boolean'}, + write: {type: 'boolean'}, + base: {type: 'string'}, + stale: {type: 'boolean'}, + budget: {type: 'boolean'}, + similar: {type: 'boolean'}, + online: {type: 'boolean'}, + }, + }); + const repo = loadRepo(repoRoot(), {ci: values.ci}); + switch (command) { + case 'find': { + if (!positionals.length) { + console.error('usage: knowledge.mts find '); + return 2; + } + for (const r of find(repo, positionals).slice(0, 15)) { + console.log( + `${r.file}${r.id ? ` [${r.id}]` : ''} (${r.score}/${positionals.length} terms)`, + ); + r.hits.forEach(h => console.log(` ${h}`)); + } + return 0; + } + case 'preview': + console.log(preview(repo, values.base)); + return 0; + case 'index': { + if (values.write) { + const written = writeGenerated(repo); + console.log( + written.length + ? written.map(f => `wrote ${f}`).join('\n') + : 'Generated files are up to date.', + ); + return 0; + } + const d = drift(repo); + d.forEach(x => console.log(`${x.file}: ${x.reason}`)); + if (!d.length) console.log('Generated files are up to date.'); + return d.length ? 1 : 0; + } + case 'check': { + const issues = runChecks(repo); + if (values.json) console.log(JSON.stringify(issues, null, 2)); + else { + for (const i of issues) { + if (values.ci && i.severity !== 'advice') { + console.log( + `::${i.severity === 'error' ? 'error' : 'warning'} file=${i.file}${i.line ? `,line=${i.line}` : ''},title=${i.check}::${i.message}`, + ); + } + console.log(formatIssue(i)); + } + const errors = issues.filter(i => i.severity === 'error').length; + console.log( + `${repo.docs.length} files checked: ${errors} errors, ${issues.length - errors} warnings.`, + ); + } + return issues.some(i => i.severity === 'error') ? 1 : 0; + } + case 'report': { + const all = !values.stale && !values.budget && !values.similar; + if (all || values.budget) { + console.log('Budgets:'); + for (const r of budgetRows(repo).sort((a, b) => b.ratio - a.ratio)) { + console.log( + ` ${(r.ratio * 100).toFixed(0).padStart(3)}% ${r.file} ${r.label}: ${r.detail}`, + ); + } + } + if (all || values.stale) { + const rows = staleRows(repo, {online: values.online}); + console.log( + 'Freshness:', + ...(rows.length ? rows.map(r => `\n ${r}`) : [' nothing stale']), + ); + } + if (all || values.similar) { + const rows = similarRows(repo); + console.log( + 'Similar text (advisory, KC-11b):', + ...(rows.length ? rows.map(r => `\n ${r}`) : [' none']), + ); + } + return 0; + } + default: + console.error( + 'usage: knowledge.mts [options]', + ); + return 2; + } +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/.agents/knowledge/tools/knowledge.test.mts b/.agents/knowledge/tools/knowledge.test.mts new file mode 100644 index 000000000000..a59b928b8884 --- /dev/null +++ b/.agents/knowledge/tools/knowledge.test.mts @@ -0,0 +1,446 @@ +/** + * Tests for knowledge.mts. Run: node --test .agents/knowledge/tools/knowledge.test.mts + * + * @format + */ + +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {test} from 'node:test'; +import { + type Issue, + loadRepo, + preview, + runChecks, + writeGenerated, +} from './knowledge.mts'; + +const TAXONOMY = `version: 1 +audited: 2026-10-28 +kinds: [rules, reference, procedure, decision, eval] +statuses: [draft, active, deprecated] +domains: {fork: Fork model, tooling: Tooling} +skills: {root: .agents/skills, links: [.claude/skills], prefix: rnmacos-, frontmatter: [name, description, license]} +budgets: {warn_ratio: 0.8, topic: {lines: 150, bytes: 8192}} +freshness: {verified_days: 180, draft_days: 30, audit_days: 45} +ownership: {upstream_owned: ['**/__docs__/**']} +homes: [AGENTS.md, .agents/knowledge, .agents/skills] +routes: [] +redirects: {} +retired_ids: [] +`; + +const AGENTS = `# React Native + + + +## Fork + +- **I1** Keep merges clean ([fork.alpha](.agents/knowledge/fork/alpha.md)). + +Start at the [index](.agents/knowledge/index.md). + +- [\`rnmacos-do-thing\`](.agents/skills/rnmacos-do-thing/SKILL.md): does a thing. + + +`; + +const POINTER = ` + +# Copilot instructions + +Follow [AGENTS.md](../AGENTS.md). +`; + +const INDEX = `# Knowledge + + + + + + + + + +`; + +const DUPLICATE = + 'Always keep this bullet long enough to count as a duplicate candidate.'; + +function topic(id: string, extra = '', body = ''): string { + return `--- +id: ${id} +summary: Summary of ${id}. +kind: rules +status: active +load_when: Testing ${id} +sources: + - package.json#scripts +verified: 2026-10-28 +rules: [first-rule] +${extra}--- + + + +# ${id} + +Use when: testing. Not for: production. + +## Rules + +- **${id}.first-rule:** ${DUPLICATE} + +## Facts + +- The \`yarn test\` command runs Jest. See [the fork rules](../../../AGENTS.md#fork). +${body}`; +} + +const SKILL = `--- +name: rnmacos-do-thing +description: Do a thing in the fixture. Use when testing. +--- + + + +# Do thing + +Read [the reference](references/ref.md). +`; + +const REF = ` + +# Reference + +Plain text. +`; + +type Files = Record; + +function baseFiles(): Files { + return { + 'package.json': JSON.stringify({scripts: {test: 'jest'}}), + '.agents/knowledge/taxonomy.yaml': TAXONOMY, + '.agents/knowledge/index.md': INDEX, + '.agents/knowledge/fork/alpha.md': topic('fork.alpha'), + 'AGENTS.md': AGENTS, + '.github/copilot-instructions.md': POINTER, + '.agents/skills/rnmacos-do-thing/SKILL.md': SKILL, + '.agents/skills/rnmacos-do-thing/references/ref.md': REF, + }; +} + +const CLAUDE_LINK = '.claude/skills/rnmacos-do-thing'; +const linkTo = (root: string, link: string, name: string) => { + fs.mkdirSync(path.dirname(path.join(root, link)), {recursive: true}); + fs.symlinkSync(`../../.agents/skills/${name}`, path.join(root, link)); +}; + +function write(root: string, files: Files) { + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), {recursive: true}); + fs.writeFileSync(path.join(root, file), text); + } +} + +const git = (root: string, ...args: string[]) => + execFileSync('git', args, {cwd: root, stdio: 'pipe', encoding: 'utf8'}); + +function fixture( + t: {after: (fn: () => void) => void}, + mutate: (files: Files) => void = () => {}, +): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'knowledge-')); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + const files = baseFiles(); + write(root, files); + linkTo(root, CLAUDE_LINK, 'rnmacos-do-thing'); + git(root, 'init', '-q'); + writeGenerated(loadRepo(root)); + git(root, 'add', '-A'); + git( + root, + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + 'commit', + '-qm', + 'base', + ); + const before = {...files}; + mutate(files); + write( + root, + Object.fromEntries( + Object.entries(files).filter(([f, v]) => before[f] !== v), + ), + ); + for (const f of Object.keys(before)) { + if (!(f in files)) fs.rmSync(path.join(root, f)); + } + git(root, 'add', '-A'); + return root; +} + +const errors = (issues: Issue[]) => issues.filter(i => i.severity === 'error'); +const has = (issues: Issue[], check: string, text?: string) => + issues.some(i => i.check === check && (!text || i.message.includes(text))); + +test('a clean fixture has no issues', t => { + const issues = runChecks(loadRepo(fixture(t), {ci: true})); + assert.deepEqual(issues, []); +}); + +test('index --write is idempotent', t => { + const root = fixture(t); + assert.deepEqual(writeGenerated(loadRepo(root)), []); +}); + +const failures: [string, string, (f: Files) => void, string?][] = [ + [ + 'title in frontmatter', + 'KC-01', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic( + 'fork.alpha', + 'title: Alpha\n', + ); + }, + 'title', + ], + [ + 'duplicate topic id', + 'KC-02', + f => { + f['.agents/knowledge/fork/beta.md'] = topic('fork.alpha'); + }, + 'duplicate id', + ], + [ + 'id that does not match its path', + 'KC-02', + f => { + f['.agents/knowledge/fork/beta.md'] = topic('fork.gamma'); + }, + 'must match the file path', + ], + [ + 'stale index', + 'KC-03', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic('fork.alpha').replace( + 'Summary of', + 'Changed', + ); + }, + ], + [ + 'broken anchor', + 'KC-04', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic('fork.alpha').replace( + '#fork', + '#missing', + ); + }, + 'missing anchor', + ], + [ + 'missing source', + 'KC-05', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic('fork.alpha').replace( + 'package.json#scripts', + 'nope.json', + ); + }, + ], + [ + 'unknown yarn command', + 'KC-06', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic( + 'fork.alpha', + '', + 'Run `yarn not-a-script` first.\n', + ); + }, + ], + [ + 'yarn script that shadows a binary', + 'KC-06', + f => { + f['package.json'] = JSON.stringify({ + scripts: {test: 'jest', prettier: 'prettier --write .'}, + devDependencies: {prettier: '*'}, + }); + f['node_modules/prettier/package.json'] = JSON.stringify({ + bin: {prettier: 'bin.js'}, + }); + f['.agents/knowledge/fork/alpha.md'] = topic( + 'fork.alpha', + '', + 'Run `yarn prettier --check a.md` first.\n', + ); + }, + 'runs the root script', + ], + [ + '@ import', + 'KC-10', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic( + 'fork.alpha', + '', + '@docs/other.md\n', + ); + }, + ], + [ + 'duplicate bullet', + 'KC-11a', + f => { + f['.agents/skills/rnmacos-do-thing/references/ref.md'] = + `${REF}\n- ${DUPLICATE}\n`; + }, + ], + [ + 'pragma in AGENTS.md', + 'KC-01', + f => { + f['AGENTS.md'] = AGENTS.replace( + '', + '\n\n', + ); + }, + 'pragma', + ], + [ + 'second fork block in AGENTS.md', + 'KC-12', + f => { + f['AGENTS.md'] = + `${AGENTS}\n\n\nMore.\n\n\n`; + }, + ], + [ + 'fork content in an upstream-owned file', + 'KC-12', + f => { + f['pkg/__docs__/notes.md'] = '# Notes\n\nA [macOS] change.\n'; + }, + 'upstream-owned', + ], + [ + 'skill missing from AGENTS.md', + 'KC-08', + f => { + f['.agents/skills/rnmacos-other-thing/SKILL.md'] = SKILL.replace( + /do-thing/g, + 'other-thing', + ); + }, + 'not listed', + ], + [ + 'pointer with extra links', + 'KC-09', + f => { + f['.github/copilot-instructions.md'] = + `${POINTER}\nSee [the index](../.agents/knowledge/index.md).\n`; + }, + ], + [ + 'failing assert', + 'KC-14', + f => { + f['.agents/knowledge/fork/alpha.md'] = topic( + 'fork.alpha', + 'asserts:\n - {file: package.json, json: scripts.test, equals: mocha}\n', + ); + }, + ], +]; + +for (const [name, check, mutate, text] of failures) { + test(`fails on ${name} (${check})`, t => { + const issues = runChecks(loadRepo(fixture(t, mutate), {ci: true})); + assert.ok(has(issues, check, text), JSON.stringify(issues, null, 2)); + }); +} + +const linkFailures: [string, (root: string) => void, string][] = [ + [ + 'a missing Claude skill link', + root => fs.rmSync(path.join(root, CLAUDE_LINK)), + 'needs a symlink', + ], + [ + 'a skill copied into the Claude folder', + root => { + fs.rmSync(path.join(root, CLAUDE_LINK)); + write(root, {[`${CLAUDE_LINK}/SKILL.md`]: SKILL}); + }, + 'holds only symlinks', + ], + [ + 'a Claude link to a missing skill', + root => linkTo(root, '.claude/skills/rnmacos-gone', 'rnmacos-gone'), + 'does not point to a skill', + ], +]; + +for (const [name, mutate, text] of linkFailures) { + test(`fails on ${name} (KC-08)`, t => { + const root = fixture(t); + mutate(root); + git(root, 'add', '-A'); + const issues = runChecks(loadRepo(root, {ci: true})); + assert.ok(has(issues, 'KC-08', text), JSON.stringify(issues, null, 2)); + }); +} + +test('passing asserts produce no issues', t => { + const root = fixture(t, f => { + f['.agents/knowledge/fork/alpha.md'] = topic( + 'fork.alpha', + 'asserts:\n - {file: package.json, json: scripts.test, equals: jest}\n', + ); + }); + writeGenerated(loadRepo(root)); + assert.deepEqual(runChecks(loadRepo(root)), []); +}); + +test('local runs see untracked files; CI runs do not', t => { + const root = fixture(t); + write(root, {'.agents/knowledge/tooling/beta.md': topic('tooling.beta')}); + const ids = (ci: boolean) => + loadRepo(root, {ci}) + .docs.filter(d => d.role === 'topic') + .map(d => d.fm?.id); + assert.deepEqual(ids(false), ['fork.alpha', 'tooling.beta']); + assert.deepEqual(ids(true), ['fork.alpha']); + assert.ok(has(runChecks(loadRepo(root)), 'KC-03')); + assert.equal(errors(runChecks(loadRepo(root, {ci: true}))).length, 0); +}); + +test('preview reports changes and writes nothing', t => { + const root = fixture(t, f => { + delete f['.agents/knowledge/fork/alpha.md']; + f['.agents/knowledge/tooling/beta.md'] = topic('tooling.beta'); + }); + const snapshot = () => + git(root, 'ls-files', '-s', '--others', '--cached', '--exclude-standard') + + git(root, 'status', '--porcelain') + + fs.readFileSync(path.join(root, '.agents/knowledge/index.md'), 'utf8'); + const before = snapshot(); + const out = preview(loadRepo(root), 'HEAD'); + assert.equal(snapshot(), before); + assert.match(out, /\+ tooling\.beta/); + assert.match(out, /- fork\.alpha .*NEEDS a redirect/); + assert.match(out, /index\.md \(stale\)/); +}); diff --git a/.agents/skills/rnmacos-organize-knowledge/SKILL.md b/.agents/skills/rnmacos-organize-knowledge/SKILL.md new file mode 100644 index 000000000000..8c63bcd59f17 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/SKILL.md @@ -0,0 +1,44 @@ +--- +name: rnmacos-organize-knowledge +description: Record, update, move, or retire react-native-macos agent knowledge in AGENTS.md, .agents/knowledge topics, rnmacos-* skills, and path pointers. Use to save a durable learning, fix stale or conflicting guidance, add or migrate a skill, change the taxonomy, or audit context cost. Defaults to a read-only preview. Not for product code or docsite pages. +license: MIT +--- + + + +# Organize react-native-macos knowledge + +## Contract + +- Start with a read-only preview. Apply changes only when the user asks you to; running non-interactively is not permission. +- Don't commit, push, install packages, or change anything remote unless the user asks. +- Keep one home per fact. Link to it; never copy it. + +## Choose a mode + +| Mode | Use when | Load | +| ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Record | Saving a new fact, rule, or pitfall | [placement](references/placement.md), [conventions](references/conventions.md) | +| Refactor | Fixing stale or conflicting guidance, or adding, splitting, renaming, or retiring a topic | [taxonomy](references/taxonomy.md), [conventions](references/conventions.md) | +| Skill | Creating, changing, or migrating a skill | [skills](references/skills.md) | +| Entry points | Changing `AGENTS.md`, the Copilot pointer, or path routes | [entrypoints](references/entrypoints.md) | +| Audit | Checking freshness, budgets, and duplication | [audit](references/audit.md) | + +Every mode ends with [validation](references/validation.md). + +## Procedure + +1. Orient: read the [knowledge index](../../../.agents/knowledge/index.md). Open a topic only when its "Load when" matches the task. +2. Find the current home: `node .agents/knowledge/tools/knowledge.mts find `. Update the existing owner, or link to code or the docsite, before you create anything. +3. Verify each claim against code, CI configuration, or an official source. Mark claims you couldn't verify "(inferred)", and give a new topic with unverified claims `status: draft`. +4. Preview: run `node .agents/knowledge/tools/knowledge.mts preview`, then show the planned edits, ID changes, redirects, index changes, and budget changes. Stop here unless the user asked you to apply. +5. Apply: edit the files, set `verified` to today's date on topics you checked, and run `node .agents/knowledge/tools/knowledge.mts index --write`. +6. Validate: follow [validation](references/validation.md) until every check passes. +7. Report: the IDs you changed, your evidence, each command with its result, any conflicts you couldn't resolve, and any context-cost problem you noticed. + +## Guardrails + +- Edit only fork-owned paths. In `AGENTS.md`, edit only inside the `[macOS` block. +- Don't add `@path` imports, and don't add a Prettier pragma to `AGENTS.md`. +- Stay within the budgets in [taxonomy.yaml](../../../.agents/knowledge/taxonomy.yaml). +- This repository is public. Never record secrets, internal links, or customer data. diff --git a/.agents/skills/rnmacos-organize-knowledge/assets/skill.template.md b/.agents/skills/rnmacos-organize-knowledge/assets/skill.template.md new file mode 100644 index 000000000000..6a51084cf2a9 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/assets/skill.template.md @@ -0,0 +1,22 @@ +--- +name: rnmacos-verb-object +description: Trigger words first. Do X to Y in react-native-macos. Use when the user asks to ... Not for ... +--- + + + +# Skill title + +## Contract + +- What the skill may change, and what needs the user's explicit request. + +## Procedure + +1. Step one. Load `references/reference-name.md` only when this step needs it, and link to it. +2. Step two. +3. Report each command you ran with its result. + +## Guardrails + +- Limits that prevent damage. diff --git a/.agents/skills/rnmacos-organize-knowledge/assets/topic.template.md b/.agents/skills/rnmacos-organize-knowledge/assets/topic.template.md new file mode 100644 index 000000000000..fd9cc5790536 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/assets/topic.template.md @@ -0,0 +1,36 @@ +--- +id: domain.topic-name +summary: One sentence that says what this topic covers. +kind: rules +status: draft +load_when: The task that needs this topic +sources: + - path/to/file#symbol +verified: YYYY-MM-DD +rules: [rule-slug] +related: [] +--- + + + +# Topic title + +Use when: the situation that needs this topic. Not for: the nearby situation that belongs to another topic. + +## Rules + +- **domain.topic-name.rule-slug:** One instruction, in the imperative. + +## Facts + +- One verified observation, with the evidence in `sources`. + +## Pitfalls + +- A mistake agents make here, and how to avoid it. + +## Verify + +```sh +command that proves the facts still hold +``` diff --git a/.agents/skills/rnmacos-organize-knowledge/references/audit.md b/.agents/skills/rnmacos-organize-knowledge/references/audit.md new file mode 100644 index 000000000000..feaf7aed3047 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/audit.md @@ -0,0 +1,20 @@ + + +# Audit + +Load this for a periodic review, after an upstream merge, or when guidance seems stale. Run it at least every 45 days. + +1. Run the reports: + + ```sh + node .agents/knowledge/tools/knowledge.mts report --stale --budget + node .agents/knowledge/tools/knowledge.mts report --similar + node .agents/knowledge/tools/knowledge.mts report --stale --online # also checks watched PRs with gh + ``` + +2. For each stale topic, re-check its sources. Fix what changed, set `verified` to today, and change `draft` to `active` once every claim is verified. +3. For each watched PR or issue that closed or merged, re-verify the facts that depend on it. +4. For near-duplicate text, keep one copy and link to it. Pairs that only share wording can stay. +5. For anything over 80% of budget, split it or move detail into a skill reference. +6. Check that each skill still triggers from a plain-language request and that the `AGENTS.md` load-on-demand table still matches common tasks. +7. Set `audited` in `taxonomy.yaml` to today, follow [validation](validation.md), and open one PR with the results. diff --git a/.agents/skills/rnmacos-organize-knowledge/references/conventions.md b/.agents/skills/rnmacos-organize-knowledge/references/conventions.md new file mode 100644 index 000000000000..79da9302de2f --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/conventions.md @@ -0,0 +1,59 @@ + + +# Knowledge conventions + +Load this when you write or edit a topic. The machine-checked parts are listed in [evals.knowledge-checks](../../../../.agents/knowledge/evals/knowledge-checks.md). + +## Layout + +- Topics live in `.agents/knowledge//.md`. Domains are one lowercase word registered in [taxonomy.yaml](../../../../.agents/knowledge/taxonomy.yaml). Topic file names are kebab-case. +- A topic's ID is `.`, matching its path, such as `fork.diff-tags` for `fork/diff-tags.md`. Moving or renaming the file changes the ID, so it needs a redirect; see [taxonomy](taxonomy.md). +- [index.md](../../../../.agents/knowledge/index.md) has a hand-written introduction and generated tables. Never edit between its `BEGIN GENERATED` and `END GENERATED` markers. + +## Topic format + +Start from [the topic template](../assets/topic.template.md). The frontmatter must be on line 1. + +| Key | Required | Value | +| --------------------- | -------- | ------------------------------------------------------------------------------------------------------------ | +| `id` | Yes | `domain.topic-name` | +| `summary` | Yes | One sentence, at most 160 characters | +| `kind` | Yes | `rules`, `reference`, `procedure`, `decision`, or `eval` | +| `status` | Yes | `draft`, `active`, or `deprecated` | +| `load_when` | Yes | The task that needs this topic, at most 120 characters | +| `sources` | Yes | List of `path`, `path:line`, `path:line@sha`, `path#symbol`, or a URL | +| `verified` | Yes | `YYYY-MM-DD`, the last date someone checked the sources | +| `rules` | No | Rule slugs; each needs a matching bullet | +| `requires`, `related` | No | Topic IDs to read first, or that are related | +| `branch_scope` | No | Branches the topic applies to, when not all | +| `watch` | No | `owner/repo#123` PRs or issues that could change the facts | +| `asserts` | No | Checks such as `{file: .yarnrc.yml, yaml: nodeLinker, equals: pnpm}`; also `json`, `matches`, and `contains` | + +Any other key is an error. Never add `title`: it duplicates the H1 and fails markdownlint MD025. + +After the frontmatter: + +1. `` on the first line after the closing `---`. +2. One H1. +3. A paragraph that starts `Use when:` and names what the topic is not for. +4. Sections in this order, skipping empty ones: Rules, Procedure, Facts, Pitfalls, Verify. + +## Rules and facts + +- A rule bullet starts with its full ID in bold: `- **domain.topic.rule-slug:** Do this.` Declare each slug in the `rules` list. Rule IDs are permanent; never reuse a retired one. +- Write rules as instructions. Write facts as observations with a source. +- End a claim you couldn't verify with "(inferred)". +- Keep one fact in one place. Link to it with a relative Markdown link and a heading anchor if needed. + +## Citations and links + +- Prefer `path#symbol` over line numbers, because symbols survive edits. Use `path:line@sha` when you need a specific revision. +- Link to the docsite for public process and link to code for behavior. Summarize; don't restate. +- Never use `@path` imports. They load the file into every session. + +## Writing style + +- Write short, direct sentences in the second person, and lead with the action. +- Use backticks for paths, commands, symbols, and literal values. +- Give every fenced code block a language. +- Keep topics within the budgets in `taxonomy.yaml`. When a topic grows past them, split it. diff --git a/.agents/skills/rnmacos-organize-knowledge/references/entrypoints.md b/.agents/skills/rnmacos-organize-knowledge/references/entrypoints.md new file mode 100644 index 000000000000..09f9ba582e49 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/entrypoints.md @@ -0,0 +1,58 @@ + + +# Entry points + +Load this when you change `AGENTS.md`, `.github/copilot-instructions.md`, path routes, or which hosts the system supports. + +## Which host reads what + +Last verified 2026-10-29. + +| Host | Always loads | Discovers skills in | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| Copilot CLI | `AGENTS.md`, `.github/copilot-instructions.md`, matching `.github/instructions/**/*.instructions.md`, `CLAUDE.md`, `.claude/rules/**/*.md` | `.github/skills`, `.claude/skills`, `.agents/skills` | +| Copilot cloud agent and VS Code | `AGENTS.md`, `.github/copilot-instructions.md`, matching `*.instructions.md` | `.github/skills`, `.claude/skills`, `.agents/skills` | +| Claude Code 2.1.277 or later | `AGENTS.md` | `.claude/skills`, following per-skill symlinks | +| Codex | `AGENTS.md` | `.agents/skills`, following symlinks | + +Copilot CLI lists its sources under `/instructions` and `/env`. A local `CLAUDE.local.md`, or a Claude Code version older than 2.1.277, can keep Claude from loading `AGENTS.md`. + +## Skill folders + +- Skills live in `.agents/skills`, the only folder that Copilot and Codex both read. Each `.claude/skills/` entry is a relative symlink to it, for Claude Code. +- Copilot CLI 1.0.105 finds a linked skill through both folders but lists it once, at its `.agents/skills` path (`copilot skill list --json`). Claude Code also loads it once. +- On Windows, a checkout without `core.symlinks` turns each link into a text file, so Claude Code there doesn't see the skill. Copilot and Codex are unaffected. + +## AGENTS.md + +- It is the only always-loaded instruction file. Upstream ships its own `AGENTS.md`, so the fork's copy keeps upstream's `# React Native` first line and puts all fork content in one `` … `` block. +- The block holds the fork rules (I1 to I7, their only home), fork-only locations, a load-on-demand table, and the skills table. Procedures, command lists, and domain detail go in topics. +- Fix upstream text that is wrong for the fork in place, and tag the line with ``. +- Budgets: the block at most 60 lines and 4 KB, and the whole file at most 150 lines and 12 KB. +- No Prettier pragma and no `@path` imports. + +## Pointers + +- `.github/copilot-instructions.md` contains a pragma, an H1, and one sentence that links to `../AGENTS.md`. Copilot loads both files, so anything more would be duplicated. +- Path pointers in `.github/instructions/rnmacos-*.instructions.md` are generated from `routes` in `taxonomy.yaml`. Never edit them by hand. + +## Files the fork doesn't add + +| File | Why not | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `CLAUDE.md` | Claude Code 2.1.277 reads `AGENTS.md` directly. Upstream's 0.88 branch adds a one-line `@AGENTS.md`; delete it at that merge. | +| `.claude/rules/` | Would duplicate path pointers. Deferred until a spike shows Copilot doesn't load both. | +| Nested `AGENTS.md` | Some hosts prefer the nearest file and would skip the root rules. | + +## Legacy files + +`.ai/commands/backport.md` and `.claude/commands/backport.md` predate this system. Migrating them into an `rnmacos-backport` skill is pending; until then, leave them as they are. + +## The 0.88 merge + +1. Start from upstream's `AGENTS.md`. +2. Insert the fork block after upstream's introduction. +3. Delete the Essentials section. +4. Correct upstream lines that are wrong for the fork, such as Yarn v1, with tagged in-place edits. +5. Delete `CLAUDE.md`. +6. Run `node .agents/knowledge/tools/knowledge.mts check`, then remove anything in `tooling.commands` that upstream's commands now cover. diff --git a/.agents/skills/rnmacos-organize-knowledge/references/placement.md b/.agents/skills/rnmacos-organize-knowledge/references/placement.md new file mode 100644 index 000000000000..67df8a382094 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/placement.md @@ -0,0 +1,23 @@ + + +# Where knowledge goes + +Load this when you have a fact, rule, or pitfall to record. Take the first answer that fits. + +1. **Can an agent derive it quickly from the code or a command?** Don't record it. +2. **Is it one-off session state, a task status, or a personal preference?** Don't record it in the repository. +3. **Does a topic or docsite page already own it?** Update that owner. Run `node .agents/knowledge/tools/knowledge.mts find ` to look. +4. **Is it public contributor process, such as releases or backports?** Put it on the docsite, and link to it from a topic. +5. **Is it architecture that upstream documents in a `__docs__` folder?** Link to that file. Never edit it. +6. **Does it explain one piece of code?** Write a code comment. +7. **Must every agent know it in every session, and does it apply across the fork?** Propose an `AGENTS.md` fork rule. This needs maintainer approval, and the block has a strict budget. +8. **Does it matter only while one skill runs?** Put it in that skill's `references/` folder. +9. **Otherwise,** put it in a topic. Use an existing topic in the right domain when one fits; create a topic only when the fact doesn't belong in any existing one. + +## Path routing + +When a topic matters whenever someone edits certain paths, add a route in `taxonomy.yaml` instead of a new `AGENTS.md` row. Routes generate `.github/instructions/rnmacos-*.instructions.md` pointer files. See [entrypoints](entrypoints.md). + +## Conflicts + +When two homes disagree, verify against the code or CI, fix the wrong one, and link it to the right one. If you can't tell which is right, leave both, mark the topic `draft`, and report the conflict. diff --git a/.agents/skills/rnmacos-organize-knowledge/references/skills.md b/.agents/skills/rnmacos-organize-knowledge/references/skills.md new file mode 100644 index 000000000000..434f6bde4bba --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/skills.md @@ -0,0 +1,34 @@ + + +# Skills + +Load this when you create, change, or migrate a skill. + +## Location and names + +- Skills live in `.agents/skills//SKILL.md`, the shared location that Copilot and Codex read directly. See [entry points](entrypoints.md#which-host-reads-what). +- Claude Code reads only `.claude/skills`, so give each skill a relative symlink there and put nothing else in it: `ln -s ../../.agents/skills/ .claude/skills/`. KC-08 enforces this. +- Write a skill's links relative to the repository root, such as `../../../.agents/knowledge/index.md`, so they resolve from both folders. +- Names follow `rnmacos--`, in lowercase kebab-case, at most 64 characters. The folder name must equal `name`. The prefix keeps fork skills from colliding with skills upstream React Native adds. +- Every skill is listed in the `AGENTS.md` skills table with a link to its `SKILL.md`. Hosts without skill discovery use that table. + +## Frontmatter + +- Allowed keys are listed under `skills.frontmatter` in [taxonomy.yaml](../../../../.agents/knowledge/taxonomy.yaml). Anything else is an error. +- Put trigger words first in `description`, say when not to use the skill, and keep it under 400 characters. Never use angle brackets in it. +- Never pre-approve shell access in `allowed-tools`. +- A skill with side effects says it acts only when the user explicitly asks. + +## Body + +- Start from [the skill template](../assets/skill.template.md). `` goes on the first line after the frontmatter. +- Keep `SKILL.md` short: a contract, a procedure, and guardrails. Move detail that only some steps need into `references/.md`, one level deep, and link each reference from the step that needs it. +- Link to knowledge topics instead of restating them. +- Put fill-in templates in `assets/` and name them `*.template.md`. + +## Migrating a command or prompt into a skill + +1. Find every place that refers to the command, including the docsite. +2. Write the skill, keeping the steps but replacing argument placeholders such as `$ARGUMENTS` with "from the request". +3. Update the references to point to the skill, and remove the old command in the same PR. +4. Test that the skill triggers from a plain-language request in each required host. diff --git a/.agents/skills/rnmacos-organize-knowledge/references/taxonomy.md b/.agents/skills/rnmacos-organize-knowledge/references/taxonomy.md new file mode 100644 index 000000000000..ac0cfca12628 --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/taxonomy.md @@ -0,0 +1,27 @@ + + +# Changing the taxonomy + +Load this when you add, split, merge, rename, deprecate, or retire a topic, or change a domain. Topic IDs are permanent addresses: other topics, routes, and skills link to them. + +## Operations + +| Operation | Steps | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Add a topic | Copy [the topic template](../assets/topic.template.md) into the domain folder. Use `status: draft` until every claim is verified. | +| Split a topic | Keep the original ID on the part most links need. Give the new part a new ID, move its rules with new rule IDs, and add the old rule IDs to `retired_ids`. | +| Merge topics | Keep one ID. Add `: ` to `redirects`, then delete the other file. | +| Rename or move | Add `: ` to `redirects`. Update every `requires`, `related`, and route that used the old ID. | +| Deprecate | Set `status: deprecated` and say what replaces it. The index stops listing it, and routes can't target it. | +| Retire | Delete the file, and add its ID to `retired_ids` so nothing reuses it. | +| Add a domain | Needs maintainer approval. Add it under `domains` with a one-line description. | + +After any operation, run `node .agents/knowledge/tools/knowledge.mts index --write`, then follow [validation](validation.md). + +## Routes + +Each entry in `routes` has `name`, `description`, `applyTo`, and `topics`. Write `applyTo` as a list of globs without braces; each glob must match at least one tracked file. Routes can target only active topics. + +## Audit date + +`audited` in `taxonomy.yaml` records the last full review. Update it only after you run the [audit](audit.md). diff --git a/.agents/skills/rnmacos-organize-knowledge/references/validation.md b/.agents/skills/rnmacos-organize-knowledge/references/validation.md new file mode 100644 index 000000000000..b03e8a6c867f --- /dev/null +++ b/.agents/skills/rnmacos-organize-knowledge/references/validation.md @@ -0,0 +1,33 @@ + + +# Validation + +Load this before you report any knowledge change. Run every command from the repository root. + +## Commands + +```sh +node .agents/knowledge/tools/knowledge.mts index --write +node .agents/knowledge/tools/knowledge.mts check +node --test .agents/knowledge/tools/knowledge.test.mts +yarn markdownlint-cli2 --no-globs AGENTS.md ".agents/knowledge/**/*.md" ".agents/skills/rnmacos-*/**/*.md" .github/copilot-instructions.md +yarn exec prettier --check path/to/changed/files +``` + +- Run `index --write` a second time and confirm it reports no changes. +- Pass Prettier only the files that have a pragma: topics, skill files, the Copilot pointer, `taxonomy.yaml`, and the `.mts` tools. `AGENTS.md` and `index.md` have none. +- Run `node --test` only when you changed `knowledge.mts` or its tests. +- CI runs `check --ci`, which ignores untracked files. Stage new files and run it before you push. + +## Telling new failures from old ones + +Run `check` on a clean checkout of the base branch, or use `git stash`. A failure that also appears there is pre-existing: report it, but don't fix unrelated content in the same change. + +## Report + +Include: + +- each command you ran with its result, such as "passed" or "1 error: KC-04 …" +- the topic and rule IDs you added, changed, redirected, or retired +- warnings you left in place, and why +- the checks you didn't run diff --git a/.claude/skills/rnmacos-organize-knowledge b/.claude/skills/rnmacos-organize-knowledge new file mode 120000 index 000000000000..763e9b36eedc --- /dev/null +++ b/.claude/skills/rnmacos-organize-knowledge @@ -0,0 +1 @@ +../../.agents/skills/rnmacos-organize-knowledge \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000000..08ac92c33126 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,5 @@ + + +# Copilot instructions + +Follow [AGENTS.md](../AGENTS.md) at the repository root. It holds the fork rules and links to everything else. diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index 6b7e015073e1..0626e6bcf10f 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -133,6 +133,15 @@ jobs: - name: Run Prettier format check run: yarn format-check + - name: Check agent knowledge + run: node .agents/knowledge/tools/knowledge.mts check --ci + + - name: Test agent knowledge tools + run: node --test .agents/knowledge/tools/knowledge.test.mts + + - name: Lint agent knowledge Markdown + run: yarn markdownlint-cli2 --no-globs AGENTS.md ".agents/knowledge/**/*.md" ".agents/skills/rnmacos-*/**/*.md" .github/copilot-instructions.md + build-rntester: name: "Build RNTester" permissions: {} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..a01aff077689 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# React Native + + + +## React Native for macOS: read first + +This is Microsoft's macOS fork of React Native. Keep changes merge-friendly, and open other files only when a table below sends you there. Where other guidance in this file conflicts with this section, this section wins. + +### Fork rules + +- **I1** Keep upstream merges clean: make the smallest change that works, and tag every macOS change in a shared file ([fork.diff-tags](.agents/knowledge/fork/diff-tags.md)). +- **I2** Respect path ownership. Never put fork content in upstream-owned files, such as `__docs__` folders or the shared lint configs ([fork.ownership](.agents/knowledge/fork/ownership.md)). +- **I3** Extend the iOS implementation with `TARGET_OS_OSX` guards and RCTUIKit types instead of copying files. +- **I4** `main` runs only the New Architecture, but the legacy (Paper) sources still compile. Keep them compiling, and make Paper behavior fixes on `*-stable` branches. +- **I5** Write PR titles as Conventional Commits. Add a Changeset only to PRs into `*-stable` ([process.pull-requests](.agents/knowledge/process/pull-requests.md)). +- **I6** Keep one home per fact and link to it instead of copying it. Change agent instructions and knowledge only through the `rnmacos-organize-knowledge` skill. +- **I7** Report each validation command you ran with its exact result, and list the checks you didn't run. + +### Where macOS code lives + +| Path | Contents | +| --- | --- | +| `packages/react-native` | The `react-native-macos` package, with macOS code inline in shared iOS files | +| `packages/react-native/ReactApple/Libraries/RCTUIKit` | UIKit and AppKit compatibility layer | +| `packages/rn-tester` | RNTester, including the `RNTester-macOS` scheme | +| `docsite/` | Fork docs: diff tags, versioning, backporting, releases | +| `.github/workflows/microsoft-*.yml` and `.ado/` | Fork CI | + +### Essentials + +- Use Yarn 4 with the pnpm linker. Run `yarn install` and all other commands from the repository root. +- Check JavaScript changes with `yarn jest `, `yarn flow-check`, `yarn lint`, and `yarn format-check`. + +### Load on demand + +| When you are | Read | +| --- | --- | +| Changing a file that upstream also has | [fork.diff-tags](.agents/knowledge/fork/diff-tags.md), [fork.ownership](.agents/knowledge/fork/ownership.md) | +| Running builds, tests, linters, or formatters | [tooling.commands](.agents/knowledge/tooling/commands.md), [tooling.format-lint](.agents/knowledge/tooling/format-lint.md) | +| Building or running RNTester-macOS | [testing.rntester-macos](.agents/knowledge/testing/rntester-macos.md) | +| Writing a PR, Changeset, or backport | [process.pull-requests](.agents/knowledge/process/pull-requests.md) | +| Doing anything else | [Knowledge index](.agents/knowledge/index.md) | + +### Skills + +- [`rnmacos-organize-knowledge`](.agents/skills/rnmacos-organize-knowledge/SKILL.md): record, fix, move, or retire agent instructions and knowledge. + +