Skip to content

refactor: one place that knows a project's layout - #372

Closed
sohail2721 wants to merge 4 commits into
devfrom
refactor/project-context-layout
Closed

sohail2721 wants to merge 4 commits into
devfrom
refactor/project-context-layout

Conversation

@sohail2721

@sohail2721 sohail2721 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Follows .ai/plans/project-context.md, which is in this PR and records both rounds of it — including the step I withdrew and then had to do.

One context

ProjectContext was named for a project and knew one thing about it: the .gitignore. BuildContext knew the source directory as a build input. Everything else about the layout was re-derived by whoever needed it.

BuildContext is deleted. Commands and actions now take a ProjectContext and nothing else — no caller joins src onto anything, and no action constructs a context of its own.

ProjectContext.at(input)        // the --input flag, absent meaning where it was run
ProjectContext.in(directory)    // for the caller handed a directory it asked the user for

  .sourceExists() .specsExist() .buildZip(temp, packageSettings?)   // was BuildContext
  .isVersioned() .hasVersions() .onlyVersion() .chosenVersion(ask)  // was BuildContext
  .portalSource() .pluginConfig() .config()                         // the contexts that read it
  .sdkDirectory(d?) .portalDirectory(d?) .pluginDirectory(d?)       // --destination overrides
  .upsertGitignore()

The versioned build, which is what made this look impossible

sdk generate builds over a versioned subdirectory — src/versioned_docs/v2 — which no project layout derives. That is a real constraint and I drew the wrong conclusion from it in the first round.

A project narrows to a version instead: the same project reading that directory and writing where it already writes.

private constructor(projectDirectory, source, version?)
private reading(v) { return new ProjectContext(this.projectDirectory, v, v.leafName()); }

ProjectContext.at() never produces the versioned case; only narrowing does, so nothing outside can construct the odd case by accident. The versioned branch of sdk generate reads better for it: versionToBuild answers the project to build from, instead of the old { version, buildContext } pair threaded through a closure that could also return an ActionResult.

What was duplicated, and is not now

  • src was spelled four ways in ten places, across commands/ and actions/. One factory now.
  • The output directory names lived twice — GENERATED (the .gitignore entries) and the same three literals in five commands. GENERATED is derived from the same names the accessors use, so a rename cannot reach one and miss the other.
  • Eight prompt sites re-derived spec/, content/ and static/ to print them. The names are exported and both sides use them; the getters stay private.

No behaviour changes. The four src spellings all resolved identically — join is new DirectoryPath(path.join(resolved, sub)) over a constructor that already calls path.resolve — so the ternary in sdk generate/sdk publish was dead weight, not a difference.

What is still exposed, and why

sourceDirectory() has eleven callers left, all inside actions. Every one hands the path to a prompt that names the directory to the reader, or to PortalArtifactsService, which uploads it. Nothing derives anything from it. Removing it entirely means prompts take contexts instead of paths, which puts file system objects in the layer whose only job is text — happy to do that, as its own change.

.ai/skills/context.md carries the narrowed exception: a context whose domain is the layout may answer with a directory it derives, and only for a caller handing it to a prompt or a service — never with its own constructor parameter. ProjectContext answers sourceDirectory() and never projectDirectory().

What is not folded in

PortalSourceContext is 630 lines of portal resolution — specs, content tree, navigation, static files — against ProjectContext's 200. Inlining it makes one class that both knows a layout and validates a docs site, and the skill's own rule about every method touching domain state stops holding. It is handed back by portalSource() instead, which is the composite pattern the skill blesses. Same for PluginConfigContext and ApimaticConfigContext, views of different blocks of one file. Say so in review if you want those folded in too.

Known, not fixed: plugin generate --destination ./out writes where .gitignore does not name, so plugin publish's git init lands in a tracked directory. ProjectContext owning both halves is what made it visible.

Reviewing

The portal fixtures are now real projects, with their contents under src/, rather than a test-only way to name a source directory outright — that is why test/resources/portal-inputs/* moved.

tsc clean on src and test, eslint clean, 1225 passing / 0 failing, all 10 checks green.

Exercised against dev after pushing:

Run Result
sdk generate --language typescript --input <dir> <dir>/sdk/typescript
sdk generate --language python from inside the project ./sdk/python
sdk generate --language csharp --destination ./elsewhere ./elsewhere/csharp
versioned build, --api-version v2 of two read src/versioned_docs/v2 → ./sdk/v2/typescript
versioned build, one version, no flag picked it without asking → ./sdk/v9/typescript
--api-version on an unversioned build warned, then built ./sdk/typescript

🤖 Generated with Claude Code

Muhammad Sohail and others added 3 commits September 25, 2026 18:37
…tself

`src` was spelled four ways in ten places, and the directories `sdk generate`,
`portal generate` and `plugin generate` write to were named as string literals
in six commands and again, independently, in the `.gitignore` entries. Nothing
made either set agree.

ProjectContext now answers for both, and GENERATED is derived from the same
names the accessors use, so a rename cannot reach one and miss the other.

The four `src` spellings resolved identically -- `new DirectoryPath(input, 'src')`
and `createInput(input).join('src')` both resolve the same path -- so this
changes no behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight prompt sites re-derived `spec/`, `content/` and `static/` from string
literals purely to print them, duplicating the private getters inside
PortalSourceContext. A prompt spelling one differently would send the reader
somewhere that does not exist.

The names are exported and both sides use them. The getters stay private:
the prompts need the name, not the path the context derives from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The context skill forbids public path getters so a caller cannot reassemble a
context's internals. ProjectContext's whole domain is the layout, so the
derivation is the thing being asked for, and withholding it sends the caller
back to the literal the context exists to replace. The exception is written
down, narrowly: a derived directory, never the constructor parameter.

The plan records that step 3 was withdrawn. BuildContext is constructed over
versioned subdirectories discovered inside a build, which no project layout can
derive, so the accessor it wanted could not have replaced those callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sohail2721
sohail2721 force-pushed the refactor/project-context-layout branch from e99330b to 715f07b Compare September 25, 2026 13:38

@mrafnadeem-apimatic mrafnadeem-apimatic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR doesn't solve the original problem I mentioned: Project and BuildContext need to be deleted and we need to use only ProjectContext .

Comment thread .ai/skills/context.md Outdated
### Encapsulation — DON'T

- **DON'T** expose internal paths as public getters — no `public get outputDirectory()` or `public get filePath()`. If a caller needs a path, it should come as a return value from an operation.
- **Exception, and only this one:** a context whose domain *is* the layout may answer with a directory it derives, as `ProjectContext.sourceDirectory()` does. The rule exists so a caller cannot reassemble a context's internals; here the derivation is the thing being asked for, and withholding it only sends the caller back to the string literal the context exists to replace. It still must be a derivation, never the constructor parameter itself — `ProjectContext` answers `sourceDirectory()` and never `projectDirectory()`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would not be needed if we just used a single context

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed, but not removed. BuildContext is gone and actions take a ProjectContext, so nothing derives a path from sourceDirectory() any more — its eleven remaining callers all hand it to a prompt that names the directory to the reader, or to PortalArtifactsService, which uploads it. Removing it entirely means prompts take contexts instead of paths, which puts file system objects in the layer whose only job is text. Tell me if you want that and I will do it as its own change.

…caller builds

Review asked for one context, and it was right that the last round did not
deliver it. What made the accessors look impossible was that every action took
a source directory; that was the thing to change, not a constraint to plan
around. Actions take a ProjectContext now, and no caller joins 'src' onto
anything.

BuildContext looked unmergeable because sdk generate builds one over a
*versioned* subdirectory, which no project layout derives. A project narrows to
a version instead: the same project reading that directory and writing where it
already writes. One private constructor covers both, and the versioned branch
of sdk generate reads better for it -- versionToBuild answers the project to
build from rather than a { version, buildContext } pair.

The portal fixtures become real projects, with their source under src/, rather
than a test-only way to name a source directory outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sohail2721

Copy link
Copy Markdown
Contributor Author

You were right, and the last round did not deliver it. Done in 47b36f2.

BuildContext is deleted. Commands and actions now take a ProjectContext and nothing else — no caller joins src onto anything, and no action constructs a context of its own.

What I got wrong. I argued the accessors could not have a caller because every action is handed a source directory. That was true, and it was the thing to change rather than a constraint to plan around; I treated the signatures as fixed when they were the work. It is about 20 files and no behaviour change.

The versioned build, which is what I claimed made it impossible: sdk generate builds a BuildContext over a versioned subdirectory (src/versions/v1), which no project layout derives. Real constraint, wrong conclusion. A project narrows to a version instead — the same project reading that directory and writing where it already writes:

private constructor(projectDirectory, source, version?)
private reading(v) { return new ProjectContext(this.projectDirectory, v, v.leafName()); }

ProjectContext.at() never produces the versioned case; only narrowing does. The versioned branch of sdk generate reads better for it: versionToBuild answers the project to build from, instead of the old { version, buildContext } pair threaded through a closure that could also return an ActionResult.

On your comment about the skill exception — it is narrower now, and I have said so in the file, but it is not gone. sourceDirectory() has eleven callers left, all inside actions, and every one hands the path to a prompt that names the directory to the reader, or to PortalArtifactsService, which uploads it. Nothing derives anything from it. Removing it entirely means prompts take contexts, which puts file system objects in the layer whose only job is text. Happy to do that if you want it, but as its own change.

What I did not delete, and why. PortalSourceContext is 630 lines of portal resolution — specs, content tree, navigation, static files — against ProjectContext's 200. Inlining it makes one class that both knows a layout and validates a docs site, and the skill's own rule about every method touching domain state stops holding. It is handed back by portalSource() instead. Same for PluginConfigContext and ApimaticConfigContext, which are views of different blocks of one file. Say the word if you want those folded in too and I will, but I did not want to do it silently.

One thing worth a look: the portal fixtures are now real projects, with their contents under src/, rather than a test-only way to name a source directory outright. That is why test/resources/portal-inputs/* moved.

tsc clean on src and test, eslint clean, 1225 passing.

@sonarqubecloud

Copy link
Copy Markdown

saeedjamshaid added a commit that referenced this pull request Sep 25, 2026
The third pre-release fix batch in .ai/plans/release-2.0-fixes.md, and the redo of PR #372's
intent on current dev:

- ProjectContext absorbs BuildContext, which is deleted. ProjectContext.at(input) and
  ProjectContext.in(directory) are the only ways in; commands and actions take the project,
  and no caller joins `src`, `sdk`, `portal` or `plugin` onto anything.
- A versioned build narrows the project to one version instead of threading a
  { version, buildContext } pair, or an ActionResult, out of a closure: versionToBuild()
  answers the project to build from or a problem the prompts name, and the three getters
  that shared one preamble are one walk.
- The layout's names live once, in types/project-layout.ts. GENERATED, the .gitignore
  entries, derives from the output names; PortalSourceContext and the prompts read the
  source directory's children from there.
- The portal fixtures are real projects, their contents under src/.
- ProjectContext gets the unit tests BuildContext never had: the layout, the versioned walk,
  the destination overrides.
- context.md records the narrowed exception for a layout context; command.md and action.md
  scaffold with ProjectContext.

No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saeedjamshaid added a commit that referenced this pull request Sep 25, 2026
…rojectContext

PR #372's review asked for Project and BuildContext to be deleted in favour of ProjectContext
alone. Quickstart's Project record still carried a raw project directory beside a
ProjectContext, so the wizard now runs as its two flows, adopting a downloaded project or
starting one, which finish the same way. The checks that a destination is not the source move
into ProjectContext as isSourceDirectory() and isSourceWithin(), so sourceDirectory() serves
only prompts and services, and context.md's exception says that.

Also from the review: the version problems reach one prompt keyed by problem, which drops the
non-Error throw SonarCloud flagged, along with its two smaller findings; and validateViaFile
opens its stream inside the try again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saeedjamshaid

Copy link
Copy Markdown
Collaborator

handled in #374

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants