diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a8534..ae37c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Changed +- **ON_DEMAND triggers inferred by `adf migrate` and `bootstrap` drop generic heading words and numbers** (`#349`). The router matches a trigger against each word of a task (exactly, or as a long enough prefix stem), so a heading such as `Release Workflow`, `Images and Components`, `Apps via Turborepo` or `Step 1` used to register triggers like `release`, `workflow`, `images`, `components`, `apps`, `via` or `1`, and the module then loaded for almost every task. `GENERIC_HEADING_WORDS` now covers the structure, process and filler words common in CLAUDE.md headings (`workflow`, `release`, `process`, `steps`, `setup`, `commands`, `files`, `code`, `apps`, `components`, `images`, `conventions`, `guide`, `via`, `using`, `when`, `how`, `all`, ...), task verbs (`add`, `fix`, `create`, `remove`, `implement`, `write`, `run`, `make`, ...) and generic nouns (`known`, `considerations`, `instructions`, `requirements`, `prerequisites`, `examples`, `support`, `engineering`): `How to Add a New Endpoint` gives `endpoint`. Domain words (`deployment`, `testing`, `database`, `auth`, `docker`, `api`, ...) are not on it. Numbers and version numbers (`1`, `2024`, `1.2`, `v2.1`, `v20.x`, `v2.0-beta`, `v2.0-beta-2`) are dropped; so is every all-digit token, including one such as `404`. A generic word that is part of the module's own file name is still used (`release` for `release.adf`). When nothing usable is left, the trigger is the module's file stem, as before. Only new registrations are affected; existing manifest entries are never rewritten. +- **On a case-insensitive filesystem, module paths are matched case-insensitively when registering modules in `manifest.adf`** (`#349`). There (Windows and macOS by default) `Infra.adf` and `infra.adf` are one file, so `adf migrate`, `bootstrap` and `adf init` no longer give it a second entry: a module listed under the other spelling is reported as already registered (`already registered as infra.adf under ON_DEMAND`; migrate's text output shows `[already registered as infra.adf]`, JSON adds `registeredAs`), and `bootstrap` no longer reports it as an unregistered module. Where two spellings are planned in one run, the module is registered once with triggers inferred from both. The filesystem is checked by looking up `manifest.adf` with its case swapped and comparing the two files' inode numbers (or, where the filesystem reports none, the directory listing), so a manifest spelled `MANIFEST.ADF` is detected too. On a case-sensitive filesystem (Linux), the two spellings are two files and the loader opens the exact name, so they are still registered separately. `charter doctor` checks a required `DEFAULT_LOAD` module (`core.adf`, `state.adf`) by its exact name, so on a case-insensitive filesystem a `DEFAULT_LOAD` entry spelled `Core.adf` is respelled in place to the name on disk (`core.adf`), changing only that token, instead of getting a second `core.adf` entry that `adf bundle` loaded as a second copy of the file (every constraint twice). JSON: `respelled: "written"` on that registration (`"planned"` under `--dry-run`, where the detail reads `would be respelled to core.adf`). If the edit cannot be made without changing anything else, the entry is left and a warning says `Core.adf and core.adf are the same file here; core.adf was not added again. Rename Core.adf to core.adf under DEFAULT_LOAD in …`. A `Core.adf` listed under `ON_DEMAND` gets the `listed under ON_DEMAND; not moved` warning (`Move Core.adf from ON_DEMAND to DEFAULT_LOAD as core.adf`) and no second entry. - **`charter adf fmt` keeps the emoji form of the standard `CONSTRAINTS`, `FILES` and `TOOLS` decorations** (`#351`). These are written with a U+FE0F variation selector (`āš ļø`, `šŸ—‚ļø`, `šŸ› ļø`), as every Charter scaffold writes `āš ļø CONSTRAINTS`. The parser drops the selector, and the formatter used to emit what it parsed, so `adf fmt --write` rewrote them to the text-style glyph (`⚠`) and `adf fmt --check` reported a freshly scaffolded file as non-canonical. The formatter now emits a section's standard decoration whenever the parsed one equals it apart from U+FE0F, whichever form the file had; any other decoration is emitted as parsed (still without U+FE0F, which the parser does not keep). **`adf fmt --write` output changes for files with a bare `⚠ CONSTRAINTS`, `šŸ—‚ FILES` or `šŸ›  TOOLS` header** (it now writes the U+FE0F form), and `adf fmt --check` reports such files as non-canonical until rewritten. The other commands that re-render a whole `.adf` file through the formatter (`adf tidy`, `adf migrate`, `adf patch`, `adf metrics`, the MCP patch tool) write the same headers. The parsed AST is unchanged, so commands that read these files behave as before. `adf sync` hashes the raw bytes, so once one of these writers rewrites a sync source, `adf sync --check` reports it as changed until `adf sync --write`. That check fails the same way after any edit. `adf populate` and the other text-preserving writers add a section header in the same form `adf fmt` emits; a non-standard text-style decoration (say `āš™ NOTES`) is no longer given a U+FE0F that `adf fmt` would then remove. New export `canonicalDecoration(key, decoration)` in `@stackbilt/adf`. This repo's `.ai/*.adf` headers were rewritten to `āš ļø CONSTRAINTS`. - **`bootstrap --ci github` and `setup --ci github` no longer overwrite a hand-edited `.github/workflows/charter-governance.yml`, and no longer silently widen its permissions** (`#374`). Without `--force`, an existing workflow is refreshed only when it is the current npm or pnpm template; a switch between the two is named (`Updated … (package manager npm → pnpm; adds pnpm/action-setup@v4)`, `packageManagerChange` in JSON). The known templates, each with a `safe` flag and a hand-written description, are listed in `commands/github-workflow.ts`. Every older Charter template had `contents: read` and ran on `pull_request` only. It is now kept with a warning that says what refreshing it changes (`contents: read → write; adds push and workflow_dispatch triggers; adds a step that commits and pushes to the repo`) and names `--force`; when the upgrade also switches package manager, the warning, the `Replaced` line and JSON (`packageManagerChange`) say so (`package manager npm → pnpm (adds pnpm/action-setup@v4)`). Before this change `bootstrap --ci github` made that upgrade silently. Any other workflow is treated as hand-edited and kept byte for byte, with a warning that states what the current template grants and asks you to review it before `--force`. Charter does not describe what a hand edit changed. The warnings name what else `--force` does in each command, and say that deleting the workflow and re-running replaces only it. Comparison drops a leading BOM, CRLF line endings, trailing whitespace and trailing blank lines. A pinned `@stackbilt/cli@x.y.z` is normalized too, but the only template that pinned one predates the current permissions, so a pinned workflow is kept unless `--force`. JSON: bootstrap lists a kept workflow in `setup.details.kept` (platform separator, like the `.charter/` entries) and records `setup.details.workflow: { file, action, reason?, changes?, templatePermissions?, backup?, packageManagerChange? }`; setup sets `workflow.kept`, `workflow.reason`, `workflow.changes` and `workflow.templatePermissions`. `reason` is `hand-edited` or `would-widen-permissions`. Under `--force` the workflow is first copied to `.charter/.backup/github-workflows-charter-governance.yml.` (gitignored), then replaced, printed as `Replaced … (backup: …; changes: …)` or `(backup: …; the current Charter template grants …)` (`setup.details.replaced` in bootstrap JSON; `workflow.backup` and an entry in top-level `replaced` in setup JSON). If that backup fails, the workflow is not replaced and the command exits 1: bootstrap lists it in `setup.details.notReplaced`, fails the setup step (JSON `status: "partial"`) and prints `Bootstrap finished with failures.` instead of `Bootstrap complete.`; setup lists it in `notReplaced` (with `reason`), sets `workflow.notReplaced: true` and prints `CI policy gate: not replaced (backup failed)`. A missing workflow is still created and an identical one left alone. `setup --ci github` without `--force` now refreshes the other package manager's current template, which it used to leave alone; setup's `mutationPlan.workflow.action` is `noop` for a workflow it will keep. diff --git a/packages/cli/src/__tests__/adf-manifest-register.test.ts b/packages/cli/src/__tests__/adf-manifest-register.test.ts index dc55e43..261776c 100644 --- a/packages/cli/src/__tests__/adf-manifest-register.test.ts +++ b/packages/cli/src/__tests__/adf-manifest-register.test.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { classifyElement, parseAdf, parseManifest } from '@stackbilt/adf'; +import { bundleModules, classifyElement, parseAdf, parseManifest, resolveModules } from '@stackbilt/adf'; import type { MigrationItem } from '@stackbilt/adf'; import { ALLOWED_SHORT_TOKENS, @@ -10,6 +10,7 @@ import { dedupePlannedRegistrations, formatRegisterDetail, inferTriggers, + isCaseInsensitiveDir, planModuleRegistration, registerManifestModules, registerModules, @@ -18,6 +19,7 @@ import { } from '../commands/adf-manifest-register'; import type { ModuleRegistration } from '../commands/adf-manifest-register'; import { NAMED_MODULE_DEFAULT_TRIGGERS } from '../commands/adf-named-scaffolds'; +import { findMissingDefaultLoad } from '../commands/adf-wiring'; // #239: the shared manifest writer and the ON_DEMAND trigger inference. @@ -56,13 +58,13 @@ describe('tokenizeTriggerText', () => { describe('inferTriggers', () => { it('weights the heading keyword above plain heading tokens', () => { - const items = [item('Deployment workflow'), item('Release workflow')].map(i => { + const items = [item('Deployment checklist'), item('Rollback checklist')].map(i => { i.classification.targetModule = 'infra.adf'; return i; }); const result = inferTriggers('infra.adf', items); - // deployment: keyword (2); workflow: 1 + 1; release: 1 — ties break by first appearance. - expect(result.triggers).toEqual(['deployment', 'workflow', 'release']); + // deployment: keyword (2); checklist: 1 + 1; rollback: 1 — ties break by first appearance. + expect(result.triggers).toEqual(['deployment', 'checklist', 'rollback']); expect(result.triggerSources).toEqual(['heading-keyword', 'heading-token', 'heading-token']); }); @@ -78,8 +80,57 @@ describe('inferTriggers', () => { it('drops tokens that already trigger another ON_DEMAND module', () => { const manifest = { onDemand: [{ path: 'frontend.adf', triggers: ['React', 'CSS'], loadPolicy: 'ON_DEMAND' as const }] }; - const result = inferTriggers('infra.adf', [routedTo('infra.adf', 'Deployment of React apps')], manifest); - expect(result.triggers).toEqual(['deployment', 'apps']); + const result = inferTriggers('infra.adf', [routedTo('infra.adf', 'Deployment of React services')], manifest); + expect(result.triggers).toEqual(['deployment', 'services']); + }); + + // Headings as they appear in real CLAUDE.md / AGENTS.md files (#349). + it.each([ + ['Build & Test Commands', ['build', 'test']], + ['Development Workflow', ['ops']], + ['Release Process (v2.1)', ['ops']], + ['Step 1: Install Dependencies', ['install', 'dependencies']], + ['Images and Components', ['ops']], + ['Apps via Turborepo', ['turborepo']], + ['Database Migrations', ['database', 'migrations']], + ['Code Style Guidelines', ['style']], + ['Testing with Vitest', ['testing', 'vitest']], + ['Deploying to Cloudflare Workers (2024)', ['deploying', 'cloudflare', 'workers']], + ['Things to Avoid', ['ops']], + ['Common Commands', ['ops']], + ['API Routes & Auth', ['api', 'routes', 'auth']], + ['Key Files and Project Structure', ['ops']], + ['Node 20 / pnpm 9 Setup', ['node', 'pnpm']], + ['Getting Started: Docker Compose', ['docker', 'compose']], + ])('heading %j yields %j', (heading, expected) => { + expect(inferTriggers('ops.adf', [routedTo('ops.adf', heading)]).triggers).toEqual(expected); + }); + + it('drops all-digit and version-number tokens', () => { + const result = inferTriggers('ops.adf', [routedTo('ops.adf', '2024 roadmap: 1.2 to v3 via kubernetes 1-2')]); + expect(result.triggers).toEqual(['roadmap', 'kubernetes']); + const versions = inferTriggers('ops.adf', [routedTo('ops.adf', 'Node.js v20.x, 1.x, v2.0-beta, v2.0-beta-2, v2.1.0-rc.1, ES2022')]); + expect(versions.triggers).toEqual(['node.js', 'es2022']); + // All-digit tokens are dropped whatever they mean: `404` too. + expect(inferTriggers('web.adf', [routedTo('web.adf', '404 Pages and Redirects')]).triggers).toEqual(['pages', 'redirects']); + }); + + it('drops task verbs and generic nouns', () => { + expect(inferTriggers('api.adf', [routedTo('api.adf', 'How to Add a New Endpoint')]).triggers).toEqual(['endpoint']); + const heading = 'Fix, Create, Remove, Implement, Write, Run, Running, Make: Known Considerations, Instructions, ' + + 'Requirements, Prerequisites, Examples, Support, Engineering for Kafka'; + expect(inferTriggers('ops.adf', [routedTo('ops.adf', heading)]).triggers).toEqual(['kafka']); + // Still used when it is the module's own stem. + expect(inferTriggers('support.adf', [routedTo('support.adf', 'Support Tickets')]).triggers).toEqual(['support', 'tickets']); + }); + + it("keeps a generic word that is the module's own stem", () => { + const items = [routedTo('release.adf', 'Release Process'), routedTo('release.adf', 'Changelog')]; + expect(inferTriggers('release.adf', items).triggers).toEqual(['release', 'changelog']); + expect(inferTriggers('release-workflow.adf', [routedTo('release-workflow.adf', 'Release workflow')]).triggers) + .toEqual(['release', 'workflow']); + expect(inferTriggers('ops.adf', items.map(i => ({ ...i, classification: { ...i.classification, targetModule: 'ops.adf' } }))).triggers) + .toEqual(['changelog']); }); it('caps at MAX_TRIGGERS', () => { @@ -270,6 +321,174 @@ describe('registerManifestModules', () => { expect(parseManifest(parseAdf(fs.readFileSync(manifestPath, 'utf-8'))).onDemand.filter(m => m.path === 'infra.adf')).toHaveLength(1); }); + describe('module path case (#349)', () => { + const ci = { onWarning, caseInsensitive: true }; + const cs = { onWarning, caseInsensitive: false }; + + it('on a case-insensitive filesystem, matches an existing entry and reports its spelling', () => { + fs.writeFileSync(manifestPath, MANIFEST); + const before = fs.readFileSync(manifestPath, 'utf-8'); + const result = registerManifestModules(manifestPath, [plan('Frontend.adf', 'Storybook')], ci); + expect(result.written).toBe(false); + expect(result.registrations.map(r => [r.module, r.status, r.loadPolicy, r.triggers])).toEqual([ + ['Frontend.adf', 'already-registered', 'ON_DEMAND', ['React', 'CSS', 'UI']], + ]); + expect(result.registrations[0].reason).toContain('already registered as frontend.adf under ON_DEMAND'); + expect(fs.readFileSync(manifestPath, 'utf-8')).toBe(before); + }); + + it('on a case-insensitive filesystem, adds one entry for two spellings in a single call', () => { + fs.writeFileSync(manifestPath, MANIFEST); + const result = registerManifestModules(manifestPath, [plan('Infra.adf'), plan('infra.adf', 'Docker')], ci); + expect(result.registrations.map(r => [r.module, r.status])).toEqual([['Infra.adf', 'added']]); + const paths = parseManifest(parseAdf(fs.readFileSync(manifestPath, 'utf-8'))).onDemand.map(m => m.path); + expect(paths).toEqual(['frontend.adf', 'Infra.adf']); + const again = registerManifestModules(manifestPath, [plan('infra.adf', 'Docker')], ci); + expect(again.registrations[0].status).toBe('already-registered'); + expect(again.written).toBe(false); + }); + + it('on a case-sensitive filesystem, registers the other spelling as its own module', () => { + fs.writeFileSync(manifestPath, MANIFEST); + const result = registerManifestModules(manifestPath, [plan('Frontend.adf', 'Storybook')], cs); + expect(result.registrations.map(r => [r.module, r.status])).toEqual([['Frontend.adf', 'added']]); + const paths = parseManifest(parseAdf(fs.readFileSync(manifestPath, 'utf-8'))).onDemand.map(m => m.path); + expect(paths).toEqual(['frontend.adf', 'Frontend.adf']); + }); + + const MISCASED = MANIFEST.replace(' - core.adf\n', ' - Core.adf\n'); + const CORE = 'ADF: 0.1\n\nāš ļø CONSTRAINTS:\n - Never commit secrets\n - Run tests before pushing\n'; + const STATE = 'ADF: 0.1\n\n\u{1F4CB} STATE:\n CURRENT: testing\n'; + + it('on a case-insensitive filesystem, respells a miscased required DEFAULT_LOAD entry in place', () => { + fs.writeFileSync(manifestPath, MISCASED); + fs.writeFileSync(path.join(tempDir, 'core.adf'), CORE); + const dry = registerManifestModules(manifestPath, [plan('core.adf')], { ...ci, dryRun: true }); + expect(dry.written).toBe(false); + expect(dry.registrations[0]).toMatchObject({ module: 'core.adf', status: 'already-registered', registeredAs: 'Core.adf', respelled: 'planned' }); + expect(dry.registrations[0].reason).toContain('would be respelled to core.adf'); + expect(formatRegisterDetail(dry.registrations[0])).toBe('core.adf → DEFAULT_LOAD [already registered as Core.adf; would be respelled to core.adf]'); + expect(fs.readFileSync(manifestPath, 'utf-8')).toBe(MISCASED); + + const result = registerManifestModules(manifestPath, [plan('core.adf')], ci); + expect(result.written).toBe(true); + expect(result.registrations[0]).toMatchObject({ module: 'core.adf', status: 'already-registered', registeredAs: 'Core.adf', respelled: 'written' }); + expect(formatRegisterDetail(result.registrations[0])).toBe('core.adf → DEFAULT_LOAD [already registered as Core.adf; respelled to core.adf]'); + expect(warnings).toEqual([]); + // Only that one token changed. + expect(fs.readFileSync(manifestPath, 'utf-8')).toBe(MANIFEST); + const again = registerManifestModules(manifestPath, [plan('core.adf')], ci); + expect(again.written).toBe(false); + expect(again.registrations[0]).toMatchObject({ status: 'already-registered' }); + expect(again.registrations[0].respelled).toBeUndefined(); + }); + + it('after the respelling, adf bundle loads each core.adf item once', () => { + fs.writeFileSync(manifestPath, MISCASED); + fs.writeFileSync(path.join(tempDir, 'core.adf'), CORE); + fs.writeFileSync(path.join(tempDir, 'state.adf'), STATE); + registerManifestModules(manifestPath, [plan('core.adf')], ci); + const manifest = parseManifest(parseAdf(fs.readFileSync(manifestPath, 'utf-8'))); + expect(manifest.defaultLoad).toEqual(['core.adf', 'state.adf']); + expect(findMissingDefaultLoad(manifest)).toEqual([]); + const bundle = bundleModules(tempDir, resolveModules(manifest, []), p => fs.readFileSync(p, 'utf-8')); + const constraints = bundle.mergedDocument.sections.find(s => s.key === 'CONSTRAINTS'); + expect(constraints?.content).toMatchObject({ type: 'list', items: ['Never commit secrets', 'Run tests before pushing'] }); + if (fs.existsSync(path.join(tempDir, 'Core.adf'))) { + // Control, on a real case-insensitive filesystem: two spellings load the file twice. + const twice = bundleModules(tempDir, ['Core.adf', 'core.adf'], p => fs.readFileSync(p, 'utf-8')); + const items = twice.mergedDocument.sections.find(s => s.key === 'CONSTRAINTS')?.content; + expect(items?.type === 'list' ? items.items.length : 0).toBe(4); + } + }); + + it('respells to the name on disk, and leaves an entry that already has it', () => { + fs.writeFileSync(manifestPath, MANIFEST.replace(' - core.adf\n', ' - CORE.adf\n')); + fs.writeFileSync(path.join(tempDir, 'Core.adf'), CORE); + const result = registerManifestModules(manifestPath, [plan('core.adf')], ci); + expect(result.registrations[0]).toMatchObject({ module: 'Core.adf', registeredAs: 'CORE.adf', respelled: 'written' }); + expect(parseManifest(parseAdf(fs.readFileSync(manifestPath, 'utf-8'))).defaultLoad).toEqual(['Core.adf', 'state.adf']); + const again = registerManifestModules(manifestPath, [plan('core.adf')], ci); + expect(again.written).toBe(false); + expect(again.registrations[0]).toMatchObject({ module: 'core.adf', status: 'already-registered', registeredAs: 'Core.adf' }); + }); + + it('warns instead of adding a duplicate when the respelling fails the guard', () => { + // The manifest reader takes Core.adf from the first DEFAULT_LOAD list (the + // last one is not a list), which the text patch does not edit. + const text = 'ADF: 0.1\n\n\u{1F4E6} DEFAULT_LOAD:\n - Core.adf\n\n\u{1F4E6} DEFAULT_LOAD: state.adf\n'; + fs.writeFileSync(manifestPath, text); + const result = registerManifestModules(manifestPath, [plan('core.adf')], ci); + expect(result.written).toBe(false); + expect(result.registrations[0]).toMatchObject({ module: 'core.adf', status: 'already-registered', registeredAs: 'Core.adf' }); + expect(result.registrations[0].respelled).toBeUndefined(); + expect(warnings).toEqual([ + `Core.adf and core.adf are the same file here; core.adf was not added again. Rename Core.adf to core.adf under DEFAULT_LOAD in ${manifestPath}`, + ]); + expect(fs.readFileSync(manifestPath, 'utf-8')).toBe(text); + }); + + it('on a case-insensitive filesystem, a miscased required module under ON_DEMAND is reported, not added again', () => { + const text = MANIFEST.replace(' - core.adf\n', '').replace('(Triggers on: React, CSS, UI)\n', '(Triggers on: React, CSS, UI)\n - Core.adf (Triggers on: core)\n'); + fs.writeFileSync(manifestPath, text); + const result = registerManifestModules(manifestPath, [plan('core.adf')], ci); + expect(result.written).toBe(false); + expect(result.registrations[0]).toMatchObject({ module: 'core.adf', status: 'already-registered', loadPolicy: 'ON_DEMAND', registeredAs: 'Core.adf' }); + expect(warnings).toEqual([ + `Core.adf is listed under ON_DEMAND in ${manifestPath}; not moved. Move Core.adf from ON_DEMAND to DEFAULT_LOAD as core.adf in .ai/manifest.adf`, + ]); + expect(fs.readFileSync(manifestPath, 'utf-8')).toBe(text); + }); + + it('on a case-sensitive filesystem, adds a required DEFAULT_LOAD module under its exact name, as doctor checks it', () => { + fs.writeFileSync(manifestPath, MISCASED); + const result = registerManifestModules(manifestPath, [plan('core.adf')], cs); + expect(result.registrations.map(r => [r.module, r.status])).toEqual([['core.adf', 'added']]); + const manifest = parseManifest(parseAdf(fs.readFileSync(manifestPath, 'utf-8'))); + expect(manifest.defaultLoad).toEqual(['Core.adf', 'state.adf', 'core.adf']); + expect(findMissingDefaultLoad(manifest)).toEqual([]); + }); + + it('registerModules plans two spellings once, from the items of both, on a case-insensitive filesystem', () => { + fs.writeFileSync(manifestPath, MANIFEST); + const items = [routedTo('Ops.adf', 'Terraform'), routedTo('ops.adf', 'Kubernetes')]; + const result = registerModules(manifestPath, ['Ops.adf', 'ops.adf'], items, ci); + expect(result.registrations.map(r => [r.module, r.status, r.triggers])).toEqual([['Ops.adf', 'added', ['terraform', 'kubernetes']]]); + fs.writeFileSync(manifestPath, MANIFEST); + const separate = registerModules(manifestPath, ['Ops.adf', 'ops.adf'], items, cs); + expect(separate.registrations.map(r => [r.module, r.status, r.triggers])).toEqual([ + ['Ops.adf', 'added', ['terraform']], + ['ops.adf', 'added', ['kubernetes']], + ]); + }); + + it('isCaseInsensitiveDir agrees with the filesystem', () => { + fs.writeFileSync(manifestPath, MANIFEST); + expect(isCaseInsensitiveDir(tempDir)).toBe(fs.existsSync(path.join(tempDir, 'MANIFEST.ADF'))); + expect(isCaseInsensitiveDir(path.join(tempDir, 'missing'))).toBe(false); + }); + + it('isCaseInsensitiveDir detects a manifest spelled MANIFEST.ADF', () => { + const upper = path.join(tempDir, 'MANIFEST.ADF'); + fs.writeFileSync(upper, MANIFEST); + const insensitive = fs.existsSync(manifestPath); + expect(isCaseInsensitiveDir(tempDir)).toBe(insensitive); + if (!insensitive) { + // Case-sensitive: two files, two spellings. + fs.writeFileSync(manifestPath, MANIFEST); + expect(isCaseInsensitiveDir(tempDir)).toBe(false); + } + }); + + it('a MANIFEST.ADF on a case-insensitive filesystem still folds module paths', () => { + fs.writeFileSync(path.join(tempDir, 'MANIFEST.ADF'), MANIFEST); + if (!fs.existsSync(manifestPath)) return; // case-sensitive: covered by the override tests above + const upperPath = path.join(tempDir, 'MANIFEST.ADF'); + const result = registerManifestModules(upperPath, [plan('Frontend.adf', 'Storybook')], { onWarning }); + expect(result.registrations[0]).toMatchObject({ status: 'already-registered', registeredAs: 'frontend.adf' }); + }); + }); + it('registerModules gives orphans the stem trigger', () => { fs.writeFileSync(manifestPath, MANIFEST); const result = registerModules(manifestPath, ['Ops.adf', 'state.adf'], [], { onWarning }); @@ -406,6 +625,8 @@ describe('REGISTER output helpers', () => { expect(formatRegisterDetail(reg({}))).toBe('infra.adf → ON_DEMAND: deploy, ci'); expect(formatRegisterDetail(reg({ status: 'planned' }))).toBe('infra.adf → ON_DEMAND: deploy, ci [planned]'); expect(formatRegisterDetail(reg({ status: 'already-registered' }))).toBe('infra.adf → ON_DEMAND: deploy, ci [already registered]'); + expect(formatRegisterDetail(reg({ status: 'already-registered', registeredAs: 'Infra.adf' }))) + .toBe('infra.adf → ON_DEMAND: deploy, ci [already registered as Infra.adf]'); expect(formatRegisterDetail(reg({ status: 'skipped', skipReason: 'manifest-missing' }))).toBe('infra.adf → ON_DEMAND: deploy, ci [skipped: manifest-missing]'); expect(formatRegisterDetail(reg({ module: 'core.adf', loadPolicy: 'DEFAULT_LOAD', triggers: [], triggerSources: [] }))).toBe('core.adf → DEFAULT_LOAD'); }); diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index bb3300f..e0cd5d9 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bootstrapCommand, completePhase } from '../commands/bootstrap'; +import { isCaseInsensitiveDir } from '../commands/adf-manifest-register'; import { doctorCommand } from '../commands/doctor'; import { driftCommand } from '../commands/drift'; import type { CLIOptions } from '../index'; @@ -197,6 +198,27 @@ STATE: expect(manifest).toContain('persona.adf'); }); + it('treats a module registered under another letter case as the filesystem does (#349)', async () => { + fs.mkdirSync('.ai', { recursive: true }); + fs.writeFileSync(path.join('.ai', 'Infra.adf'), 'ADF: 0.1\n\nCONTEXT:\n - Infra rules\n'); + fs.writeFileSync( + path.join('.ai', 'manifest.adf'), + 'ADF: 0.1\n\n\u{1F4E6} DEFAULT_LOAD:\n - core.adf\n - state.adf\n\n\u{1F4C2} ON_DEMAND:\n - infra.adf (Triggers on: docker)\n', + ); + + const exitCode = await bootstrapCommand( + { ...baseOptions, yes: true }, + ['--yes', '--preset', 'worker', '--skip-install', '--skip-doctor'], + ); + + expect(exitCode).toBe(0); + // One file on a case-insensitive filesystem; two distinct modules on a case-sensitive one. + const sameFile = isCaseInsensitiveDir('.ai'); + expect(logs.find(l => l.includes('unregistered .adf module'))?.includes('Infra.adf') ?? false).toBe(!sameFile); + const manifest = fs.readFileSync(path.join('.ai', 'manifest.adf'), 'utf-8'); + expect(manifest.match(/infra\.adf/gi)).toHaveLength(sameFile ? 1 : 2); + }); + it('seeds security-sensitive bootstrap files and warns when security tests are absent', async () => { const exitCode = await bootstrapCommand( { ...baseOptions, yes: true }, diff --git a/packages/cli/src/commands/adf-manifest-register.ts b/packages/cli/src/commands/adf-manifest-register.ts index 7a4b40a..9f60009 100644 --- a/packages/cli/src/commands/adf-manifest-register.ts +++ b/packages/cli/src/commands/adf-manifest-register.ts @@ -5,7 +5,9 @@ * user's behalf: `charter adf migrate` registers the modules it routes content * into, and `charter bootstrap` registers orphaned modules. The writer is * append-only: an entry whose parsed path is already in DEFAULT_LOAD or - * ON_DEMAND is never rewritten, moved, or merged with new triggers. + * ON_DEMAND is never moved or merged with new triggers. The one rewrite: on a + * case-insensitive filesystem, a DEFAULT_LOAD entry for core.adf or state.adf + * spelled in another case is respelled to the name on disk (#349). * * ON_DEMAND triggers are inferred from the headings of the items routed to the * module (see inferTriggers). `charter adf create` keeps its own upsert writer. @@ -15,7 +17,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseAdf, parseManifest, formatTriggerEntry } from '@stackbilt/adf'; import type { AdfDocument, MigrationItem } from '@stackbilt/adf'; -import { classifyModuleLoad, normalizeModulePath } from './adf-wiring'; +import { classifyModuleLoad, isRequiredDefaultLoad, normalizeModulePath } from './adf-wiring'; import { NAMED_MODULE_DEFAULT_TRIGGERS } from './adf-named-scaffolds'; // ============================================================================ @@ -34,9 +36,38 @@ export const HEADING_KEYWORD_WEIGHT = 2; /** The 2-letter terms in the classifier's heading-route vocabulary. */ export const ALLOWED_SHORT_TOKENS: readonly string[] = ['ui', 'ci', 'cd', 'db', 'kv', 'd1', 'r2']; -/** Generic heading words that carry no routing signal. */ +/** + * Words common in CLAUDE.md / AGENTS.md headings that carry no routing signal + * (#349): document structure, process and directive words, and filler. The + * router matches a trigger against each word of the task (exactly, or as a + * long enough prefix stem: see isKeywordMatch in @stackbilt/adf), so these would + * load the module for almost every request. Domain words (deployment, testing, database, auth, + * docker, api, ...) are deliberately absent. A word that is also the module's + * own file stem is still used (see inferTriggers). + */ export const GENERIC_HEADING_WORDS: readonly string[] = [ - 'rules', 'guidelines', 'notes', 'overview', 'section', 'project', + // document structure + 'rules', 'rule', 'guidelines', 'guideline', 'guide', 'notes', 'note', 'overview', 'section', + 'project', 'introduction', 'intro', 'summary', 'details', 'reference', 'info', 'information', + 'misc', 'miscellaneous', 'general', 'other', 'others', 'additional', 'appendix', 'faq', 'tips', + 'usage', 'getting', 'started', 'quick', 'start', 'basics', 'basic', 'things', 'stuff', 'items', + 'list', 'todo', 'context', 'key', 'important', 'common', 'main', 'new', + // process and repository-shape words + 'workflow', 'workflows', 'process', 'processes', 'steps', 'step', 'phase', 'stage', 'release', + 'releases', 'changes', 'change', 'updates', 'update', 'development', 'dev', 'working', 'work', + 'setup', 'conventions', 'convention', 'practices', 'practice', 'best', 'standards', 'standard', + 'patterns', 'pattern', 'structure', 'organization', 'commands', 'command', 'files', 'file', + 'code', 'apps', 'app', 'components', 'images', 'tasks', 'task', + // task verbs and generic nouns ("How to Add a New Endpoint", "Known Issues") + 'add', 'fix', 'create', 'remove', 'implement', 'write', 'run', 'running', 'make', 'known', + 'considerations', 'instructions', 'requirements', 'prerequisites', 'examples', 'support', + 'engineering', + // directive and filler words + 'via', 'using', 'use', 'when', 'how', 'what', 'why', 'where', 'which', 'who', 'all', 'any', + 'each', 'every', 'some', 'more', 'most', 'only', 'also', 'not', 'dont', 'don', 'can', 'should', + 'must', 'will', 'may', 'always', 'never', 'avoid', 'here', 'there', 'then', 'than', 'over', + 'under', 'out', 'before', 'after', 'during', 'without', 'within', 'between', 'per', 'etc', + 'one', 'two', 'three', 'first', 'next', 'last', ]; /** @@ -83,6 +114,17 @@ export interface ModuleRegistration { skipReason?: SkipReason; /** Where each trigger came from; same length and order as `triggers`. */ triggerSources: TriggerInferenceSource[]; + /** + * 'already-registered' only: the spelling the manifest lists the module + * under, when it differs from `module` (case-insensitive filesystem, #349). + */ + registeredAs?: string; + /** + * 'already-registered' only: the DEFAULT_LOAD entry `registeredAs` was + * respelled to `module`, the name on disk ('written'), or would be under + * dryRun ('planned'). + */ + respelled?: 'written' | 'planned'; } export interface RegisterManifestModulesResult { @@ -95,6 +137,8 @@ export interface RegisterManifestModulesResult { export interface RegisterOptions { dryRun?: boolean; onWarning?: (message: string) => void; + /** Compare module paths case-insensitively. Default: isCaseInsensitiveDir(the manifest's directory). */ + caseInsensitive?: boolean; } type ParsedManifest = ReturnType; @@ -117,11 +161,68 @@ export function tokenizeTriggerText(text: string): string[] { .filter(t => t.length > 0); } -function isUsableToken(token: string): boolean { - if (STOPWORDS.has(token) || GENERIC.has(token)) return false; +/** + * A heading token worth keeping as a trigger. Stopwords, numbers and version + * numbers (`2`, `404`, `2024`, `1.2`, `v2.1`, `v20.x`, `v2.0-beta`, `v2.0-beta-2`) and short tokens outside + * ALLOWED_SHORT_TOKENS never are; generic heading words are, only when they + * are part of the module's own stem (`release` for release.adf). + */ +function isUsableToken(token: string, stemTokens: ReadonlySet): boolean { + if (STOPWORDS.has(token) || /^v?[0-9]+(?:[._-](?:[0-9]+|x))*(?:-[a-z]+(?:[._-]?[0-9]+)?)?$/.test(token)) return false; + if (GENERIC.has(token) && !stemTokens.has(token)) return false; return token.length >= MIN_TOKEN_LEN || ALLOWED_SHORT.has(token); } +/** + * The key two module paths are compared by when deciding whether they name + * one file: the normalized path, lowercased when `caseInsensitive` (#349). + * Only fold where the filesystem does: on Linux `Infra.adf` and `infra.adf` + * are two files and the loader opens the exact name. + */ +export function moduleKey(modulePath: string, caseInsensitive: boolean): string { + const normalized = normalizeModulePath(modulePath); + return caseInsensitive ? normalized.toLowerCase() : normalized; +} + +/** + * True when `dir` is on a case-insensitive filesystem (Windows and macOS by + * default): `name` (an entry of `dir`, in any case) is also found with its + * case swapped, and both spellings are one file. Where the filesystem reports + * inode numbers, they are compared; where it reports 0 (some Windows + * filesystems), both spellings being listed as entries of `dir` means two files. + * So a manifest actually spelled `MANIFEST.ADF` is detected too. + */ +export function isCaseInsensitiveDir(dir: string, name = 'manifest.adf'): boolean { + const swapped = [...name].map(c => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join(''); + if (swapped === name) return false; + try { + const a = fs.statSync(path.join(dir, name), { bigint: true, throwIfNoEntry: false }); + const b = fs.statSync(path.join(dir, swapped), { bigint: true, throwIfNoEntry: false }); + if (!a || !b) return false; + if (a.ino !== 0n && b.ino !== 0n) return a.dev === b.dev && a.ino === b.ino; + const entries = fs.readdirSync(dir); + return !(entries.includes(name) && entries.includes(swapped)); + } catch { + return false; + } +} + +/** + * The spelling `module` has on disk under `dir`, when a case-insensitive + * lookup finds exactly one entry for it; otherwise undefined. + */ +function onDiskSpelling(dir: string, module: string): string | undefined { + const full = path.join(dir, module); + const base = path.basename(full).toLowerCase(); + try { + const hits = fs.readdirSync(path.dirname(full)).filter(f => f.toLowerCase() === base); + if (hits.length !== 1) return undefined; + return normalizeModulePath(path.posix.join(path.posix.dirname(normalizeModulePath(module)), hits[0])); + } catch { + return undefined; + } +} + /** The stem fallback trigger: the module's file stem, lowercased and tokenized. */ function stemTrigger(module: string): string { const stem = path.posix.basename(module, '.adf'); @@ -161,6 +262,7 @@ export function inferTriggers( const stem = path.posix.basename(target, '.adf'); for (const seed of NAMED_MODULE_DEFAULT_TRIGGERS[stem] ?? []) push(seed.trim().toLowerCase(), 'named-seed'); + const stemTokens = new Set(tokenizeTriggerText(stem).flatMap(t => t.split(/[._-]/))); const scores = new Map(); for (const item of items) { @@ -174,7 +276,7 @@ export function inferTriggers( if (!perItem.has(t)) perItem.set(t, 'heading-token'); } for (const [token, source] of perItem) { - if (!isUsableToken(token) || taken.has(token)) continue; + if (!isUsableToken(token, stemTokens) || taken.has(token)) continue; const weight = source === 'heading-keyword' ? HEADING_KEYWORD_WEIGHT : 1; const entry = scores.get(token); if (entry) { @@ -362,17 +464,25 @@ export function lastSectionIndex(doc: AdfDocument, key: string): number { } /** An existing entry, reported as the manifest has it: real section, triggers as spelled. */ -function alreadyRegistered(entry: ModuleRegistration, policy: LoadPolicy, triggers: readonly string[]): ModuleRegistration { +function alreadyRegistered(entry: ModuleRegistration, policy: LoadPolicy, triggers: readonly string[], spelled: string): ModuleRegistration { + const as = spelled !== entry.module ? ` as ${spelled}` : ''; return { module: entry.module, loadPolicy: policy, triggers: [...triggers], - reason: `already registered under ${policy}; existing entry kept, triggers not inferred`, + reason: `already registered${as} under ${policy}; existing entry kept, triggers not inferred`, status: 'already-registered', triggerSources: [], + ...(spelled !== entry.module ? { registeredAs: spelled } : {}), }; } +/** Index, as parseAdf counts list items, of the item whose path is `modulePath`; -1 if none. */ +function listItemIndex(section: AdfDocument['sections'][number] | undefined, modulePath: string): number { + if (section?.content.type !== 'list') return -1; + return section.content.items.findIndex(item => normalizeModulePath(item) === modulePath); +} + /** * Register planned modules in manifest.adf. Append-only and idempotent: an * entry already present (matched by normalized parsed path, in either section) @@ -381,8 +491,15 @@ function alreadyRegistered(entry: ModuleRegistration, policy: LoadPolicy, trigge * The manifest is parsed only to decide; new bullets are inserted as text, so * every other byte (comments, ordering, line endings) survives. Before writing, * the edited text is re-parsed and must equal the old document plus exactly - * the new entries; otherwise nothing is written. Under dryRun nothing is - * written and would-be additions stay 'planned'. + * the new entries (and any respelled required entry); otherwise nothing is + * written. Under dryRun nothing is written and would-be additions stay 'planned'. + * + * On a case-insensitive filesystem, a required DEFAULT_LOAD module (core.adf, + * state.adf) listed under another spelling (`Core.adf`) is one file, so it is + * not added again: its DEFAULT_LOAD bullet is respelled in place to the name + * on disk (status 'already-registered', `respelled`), because doctor + * checks the exact name. When that edit fails the guard, the entry is kept and + * a warning says to rename it. Listed under ON_DEMAND, it is reported and left. */ export function registerManifestModules( manifestPath: string, @@ -391,7 +508,9 @@ export function registerManifestModules( ): RegisterManifestModulesResult { const warn = options.onWarning ?? (() => undefined); // One registration per module: a repeated module keeps its first entry. - const entries = planned.filter((e, i) => planned.findIndex(o => o.module === e.module) === i); + const fold = options.caseInsensitive ?? isCaseInsensitiveDir(path.dirname(manifestPath)); + const keyOf = (p: string): string => moduleKey(p, fold); + const entries = planned.filter((e, i) => planned.findIndex(o => keyOf(o.module) === keyOf(e.module)) === i); if (!fs.existsSync(manifestPath)) { if (entries.length > 0) { @@ -416,22 +535,76 @@ export function registerManifestModules( } // DEFAULT_LOAD wins when a module is listed in both sections. - const existing = new Map(); - for (const mod of manifest.onDemand) existing.set(normalizeModulePath(mod.path), { policy: 'ON_DEMAND', triggers: mod.triggers }); - for (const mod of manifest.defaultLoad) existing.set(normalizeModulePath(mod), { policy: 'DEFAULT_LOAD', triggers: [] }); + // Keyed by moduleKey: on a case-insensitive filesystem `Infra.adf` finds an + // existing `infra.adf` entry. `exact` holds the spellings as listed: a + // required module found only under another spelling is respelled (below). + const existing = new Map(); + for (const mod of manifest.onDemand) existing.set(keyOf(mod.path), { policy: 'ON_DEMAND', triggers: mod.triggers, path: normalizeModulePath(mod.path) }); + for (const mod of manifest.defaultLoad) existing.set(keyOf(mod), { policy: 'DEFAULT_LOAD', triggers: [], path: normalizeModulePath(mod) }); + const exact = new Set([...manifest.onDemand.map(m => m.path), ...manifest.defaultLoad].map(normalizeModulePath)); const expected: AdfDocument = JSON.parse(JSON.stringify(doc)); const registrations: ModuleRegistration[] = []; const pending: number[] = []; let next = text; + const respelled: number[] = []; + const sameFileWarning = (spelled: string, module: string): string => + `${spelled} and ${module} are the same file here; ${module} was not added again. Rename ${spelled} to ${module} under DEFAULT_LOAD in ${manifestPath}`; + for (const entry of entries) { - const found = existing.get(entry.module); + const found = existing.get(keyOf(entry.module)); + const required = isRequiredDefaultLoad(entry.module); + // An entry found under another spelling (case-insensitive filesystem only) + // is the same file. Doctor checks a required module by its exact name, so a + // required module's DEFAULT_LOAD bullet is respelled below rather than + // given a second bullet, which `adf bundle` would load twice. + if (found && (exact.has(entry.module) || !required || found.policy === 'ON_DEMAND')) { + if (required && found.policy === 'ON_DEMAND') { + const as = found.path !== entry.module ? ` as ${entry.module}` : ''; + warn(`${found.path} is listed under ON_DEMAND in ${manifestPath}; not moved. Move ${found.path} from ON_DEMAND to DEFAULT_LOAD${as} in .ai/manifest.adf`); + } + registrations.push(alreadyRegistered(entry, found.policy, found.triggers, found.path)); + continue; + } if (found) { - if (entry.loadPolicy === 'DEFAULT_LOAD' && found.policy === 'ON_DEMAND') { - warn(`${entry.module} is listed under ON_DEMAND in ${manifestPath}; not moved. Move ${entry.module} from ON_DEMAND to DEFAULT_LOAD in .ai/manifest.adf`); + // Respell to the name on disk (the planned name when no file is found). + const target = onDiskSpelling(path.dirname(manifestPath), entry.module) ?? entry.module; + if (target === found.path) { + registrations.push(alreadyRegistered(entry, 'DEFAULT_LOAD', [], found.path)); + continue; + } + const reg = alreadyRegistered({ ...entry, module: target }, 'DEFAULT_LOAD', [], found.path); + // Same guard as the appends: the re-parsed edit must differ from the + // document only in that one item. + const idx = lastSectionIndex(expected, 'DEFAULT_LOAD'); + const itemIndex = idx === -1 ? -1 : listItemIndex(expected.sections[idx], found.path); + const candidate = itemIndex === -1 ? null : replaceManifestBullet(next, 'DEFAULT_LOAD', itemIndex, target); + const candidateExpected: AdfDocument = JSON.parse(JSON.stringify(expected)); + const content = idx === -1 ? undefined : candidateExpected.sections[idx].content; + if (content?.type === 'list' && itemIndex !== -1) content.items[itemIndex] = target; + let ok = false; + try { + ok = candidate !== null && JSON.stringify(parseAdf(candidate)) === JSON.stringify(candidateExpected); + } catch { + ok = false; } - registrations.push(alreadyRegistered(entry, found.policy, found.triggers)); + if (!ok || candidate === null) { + warn(sameFileWarning(found.path, target)); + registrations.push(reg); + continue; + } + next = candidate; + expected.sections[idx] = candidateExpected.sections[idx]; + existing.set(keyOf(target), { policy: 'DEFAULT_LOAD', triggers: [], path: target }); + exact.delete(found.path); + exact.add(target); + respelled.push(registrations.length); + registrations.push({ + ...reg, + respelled: options.dryRun ? 'planned' : 'written', + reason: `already registered as ${found.path} under DEFAULT_LOAD, the same file here; entry ${options.dryRun ? 'would be ' : ''}respelled to ${target}`, + }); continue; } @@ -457,12 +630,13 @@ export function registerManifestModules( } else { section.content = { type: 'list', items: [bullet] }; } - existing.set(entry.module, { policy: key, triggers: entry.triggers }); + existing.set(keyOf(entry.module), { policy: key, triggers: entry.triggers, path: entry.module }); + exact.add(entry.module); pending.push(registrations.length); registrations.push({ ...entry, status: options.dryRun ? 'planned' : 'added' }); } - if (pending.length === 0) return { manifestPath, written: false, registrations }; + if (pending.length === 0 && respelled.length === 0) return { manifestPath, written: false, registrations }; // Guard: the edit must change nothing but the new entries. let safe = false; @@ -472,8 +646,16 @@ export function registerManifestModules( safe = false; } if (!safe) { - warn(`${manifestPath} could not be updated without changing its other entries; ${pending.length} module(s) were not registered. Fix the manifest or run: charter doctor`); + if (pending.length > 0) { + warn(`${manifestPath} could not be updated without changing its other entries; ${pending.length} module(s) were not registered. Fix the manifest or run: charter doctor`); + } for (const i of pending) registrations[i] = { ...registrations[i], status: 'skipped', skipReason: 'manifest-unparseable' }; + for (const i of respelled) { + const { respelled: _dropped, ...reg } = registrations[i]; + const spelled = reg.registeredAs ?? reg.module; + warn(sameFileWarning(spelled, reg.module)); + registrations[i] = { ...reg, reason: `already registered as ${spelled} under DEFAULT_LOAD, the same file here; existing entry kept` }; + } return { manifestPath, written: false, registrations }; } @@ -498,12 +680,18 @@ export function registerModules( } catch { // The writer reports an unparseable manifest as skipped. } - const planned = modules.map(m => planModuleRegistration( + // Where two spellings name one file, plan it once from the items of both. + const fold = options.caseInsensitive ?? isCaseInsensitiveDir(path.dirname(manifestPath)); + const keyOf = (p: string): string => moduleKey(p, fold); + const unique = modules.filter((m, i) => modules.findIndex(o => keyOf(o) === keyOf(m)) === i); + const planned = unique.map(m => planModuleRegistration( m, - items.filter(i => i.classification.targetModule === m), + items + .filter(i => keyOf(i.classification.targetModule) === keyOf(m)) + .map(i => (i.classification.targetModule === m ? i : { ...i, classification: { ...i.classification, targetModule: m } })), manifest, )); - return registerManifestModules(manifestPath, planned, options); + return registerManifestModules(manifestPath, planned, { ...options, caseInsensitive: fold }); } // ============================================================================ @@ -521,8 +709,12 @@ export interface RegisterAction { /** `infra.adf → ON_DEMAND: deploy, ci [planned]` */ export function formatRegisterDetail(reg: ModuleRegistration): string { + const as = reg.registeredAs ? ` as ${reg.registeredAs}` : ''; + const respelled = reg.respelled === 'written' ? `; respelled to ${reg.module}` + : reg.respelled === 'planned' ? `; would be respelled to ${reg.module}` + : ''; const suffix = reg.status === 'planned' ? ' [planned]' - : reg.status === 'already-registered' ? ' [already registered]' + : reg.status === 'already-registered' ? ` [already registered${as}${respelled}]` : reg.status === 'skipped' ? ` [skipped: ${reg.skipReason}]` : ''; return reg.loadPolicy === 'ON_DEMAND' diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 1ab7fb7..f23819b 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -53,7 +53,7 @@ import type { CompileTarget } from '@stackbilt/adf'; import { discoverAgentConfigFiles, migrateSource, updateModuleIndex } from './adf-migrate'; import { inferVendorMode, recordedModeOf, recordWrittenVendorModes, resolveVendorMode } from './adf-vendor'; import type { VendorFile } from './adf-vendor'; -import { registerModules } from './adf-manifest-register'; +import { isCaseInsensitiveDir, moduleKey, registerModules } from './adf-manifest-register'; import { backupFile, describeMerge, dirLabel, formatLossLines, tryEnsureBackupGitignore } from './adf-scaffold-merge'; import type { ScaffoldMode, ScaffoldOutcome } from './adf-scaffold-merge'; import { confirmReset, finalizeLoss, promptYesNo, resetDeclinedMessage, resetRefusalMessage, writeAdfScaffolds } from './adf-scaffold-write'; @@ -1134,11 +1134,14 @@ function runAdfInitPhase( const allAdfFiles = fs.readdirSync(aiDir).filter(f => f.endsWith('.adf') && f !== 'manifest.adf'); const doc = parseAdf(manifestContent); const manifest = parseManifest(doc); + // Compared as the manifest writer compares them (#349): on a case-insensitive + // filesystem an `infra.adf` entry registers `Infra.adf`. + const fold = isCaseInsensitiveDir(aiDir); const registeredModules = new Set([ ...manifest.defaultLoad, ...manifest.onDemand.map(m => m.path), - ]); - const orphans = allAdfFiles.filter(f => !registeredModules.has(f)); + ].map(m => moduleKey(m, fold))); + const orphans = allAdfFiles.filter(f => !registeredModules.has(moduleKey(f, fold))); if (orphans.length > 0) { detectedOrphans.push(...orphans); warnings.push(`Found ${orphans.length} unregistered .adf module(s): ${orphans.join(', ')}`);