frontend-project-context 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/PROJECT_STATE.json +176 -0
- package/README.md +148 -0
- package/RTK.md +13 -0
- package/UPGRADING.md +15 -0
- package/bin/project-context.mjs +7 -0
- package/docs/00-PRODUCT-CONSTITUTION.md +166 -0
- package/docs/01-PRODUCT-CORE.md +143 -0
- package/docs/02-MARKET-BOUNDARY.md +88 -0
- package/docs/03-FINAL-SOLUTION.md +203 -0
- package/docs/04-PROGRAM-DESIGN.md +428 -0
- package/docs/05-ACCEPTANCE-CONTRACT.md +348 -0
- package/docs/06-HISTORICAL-PROTOTYPE.md +55 -0
- package/docs/07-REAL-TASK-EVIDENCE.md +52 -0
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +199 -0
- package/docs/09-B0-DTG-TMC-MOBILE.md +173 -0
- package/docs/10-B0-DTG-TMC-PC.md +118 -0
- package/docs/11-V1-AUTHORING-CLOSURE-DESIGN.md +312 -0
- package/docs/12-KNOWLEDGE-MAINTENANCE-CLOSURE-ROADMAP.md +350 -0
- package/docs/13-READ-ONLY-GOVERNANCE-DASHBOARD-DESIGN.md +489 -0
- package/docs/14-FORMAL-RELEASE-READINESS.md +61 -0
- package/docs/15-SOURCE-LIFECYCLE-CLOSURE-DESIGN.md +260 -0
- package/docs/README.md +74 -0
- package/examples/README.md +17 -0
- package/examples/package.json +11 -0
- package/examples/project-context-check.yml +22 -0
- package/package.json +40 -0
- package/src/project-context/approver.mjs +177 -0
- package/src/project-context/authoring.mjs +190 -0
- package/src/project-context/canonical-json.mjs +55 -0
- package/src/project-context/checker.mjs +132 -0
- package/src/project-context/cli.mjs +409 -0
- package/src/project-context/contract-schema.mjs +316 -0
- package/src/project-context/dashboard-model.mjs +278 -0
- package/src/project-context/dashboard-renderer.mjs +637 -0
- package/src/project-context/discovery.mjs +251 -0
- package/src/project-context/errors.mjs +13 -0
- package/src/project-context/io.mjs +93 -0
- package/src/project-context/maintenance.mjs +400 -0
- package/src/project-context/path-policy.mjs +155 -0
- package/src/project-context/project-store.mjs +138 -0
- package/src/project-context/projection-store.mjs +107 -0
- package/src/project-context/renderer.mjs +135 -0
- package/src/project-context/scope-compiler.mjs +132 -0
- package/src/project-context/source-reader.mjs +124 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sha256 } from "./canonical-json.mjs";
|
|
4
|
+
import { sourceRegistrationShape, sourceStatus } from "./contract-schema.mjs";
|
|
5
|
+
import { DEFAULT_IGNORES, digestPath, digestPathIdentity, readSourceDigest } from "./source-reader.mjs";
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILES = [
|
|
8
|
+
"tsconfig.json",
|
|
9
|
+
"eslint.config.js",
|
|
10
|
+
"eslint.config.mjs",
|
|
11
|
+
".eslintrc",
|
|
12
|
+
".eslintrc.json",
|
|
13
|
+
".prettierrc",
|
|
14
|
+
".prettierrc.json",
|
|
15
|
+
"vite.config.js",
|
|
16
|
+
"vite.config.ts",
|
|
17
|
+
"vitest.config.js",
|
|
18
|
+
"vitest.config.ts",
|
|
19
|
+
"jest.config.js",
|
|
20
|
+
"jest.config.ts",
|
|
21
|
+
"playwright.config.js",
|
|
22
|
+
"playwright.config.ts",
|
|
23
|
+
];
|
|
24
|
+
const TOOL_DEPENDENCIES = ["react", "vue", "next", "nuxt", "vite", "typescript", "eslint", "prettier", "vitest", "jest", "@playwright/test"];
|
|
25
|
+
|
|
26
|
+
function slug(value) {
|
|
27
|
+
return value
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replaceAll(/[^a-z0-9]+/gu, "-")
|
|
30
|
+
.replaceAll(/^-+|-+$/gu, "") || "root";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function generatedId(prefix, value) {
|
|
34
|
+
return `${prefix}-${slug(value)}-${sha256(value).slice(7, 15)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fileOrNull(filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return await readFile(filePath);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code === "ENOENT") return null;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function discoverRuleFiles(root) {
|
|
47
|
+
const results = [];
|
|
48
|
+
async function visit(directory, prefix, depth) {
|
|
49
|
+
if (depth > 8) return;
|
|
50
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (DEFAULT_IGNORES.has(entry.name)) continue;
|
|
53
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
54
|
+
if (entry.isSymbolicLink()) continue;
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
await visit(path.join(directory, entry.name), relative, depth + 1);
|
|
57
|
+
} else if (entry.isFile()) {
|
|
58
|
+
const basename = entry.name.toLowerCase();
|
|
59
|
+
const inKnownDirectory = relative.startsWith(".kiro/steering/") || relative.startsWith(".ruler/");
|
|
60
|
+
if (basename === "agents.md" || basename === "claude.md" || (inKnownDirectory && basename.endsWith(".md"))) {
|
|
61
|
+
results.push(relative);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await visit(root, "", 0);
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function discoverTopLevelDirectories(root) {
|
|
71
|
+
const entries = (await readdir(root, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
72
|
+
return entries
|
|
73
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith(".") && !DEFAULT_IGNORES.has(entry.name))
|
|
74
|
+
.map((entry) => entry.name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ruleAliasTarget(relative, contents) {
|
|
78
|
+
const match = contents.trim().match(/^@([^\s]+)$/u);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const rawTarget = match[1].replaceAll("\\", "/");
|
|
81
|
+
if (path.posix.isAbsolute(rawTarget)) return null;
|
|
82
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(relative), rawTarget));
|
|
83
|
+
if (target === "." || target.startsWith("../")) return null;
|
|
84
|
+
return target;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function aliasesWithConcreteTargets(aliasTargets, knownPaths) {
|
|
88
|
+
const result = new Set();
|
|
89
|
+
for (const start of aliasTargets.keys()) {
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
let current = start;
|
|
92
|
+
while (aliasTargets.has(current) && !seen.has(current)) {
|
|
93
|
+
seen.add(current);
|
|
94
|
+
current = aliasTargets.get(current);
|
|
95
|
+
}
|
|
96
|
+
if (!seen.has(current) && knownPaths.has(current)) result.add(start);
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function proposalItem({ id, kind = "fact", subject, value, statement, source, scope = { kind: "project" } }) {
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
kind,
|
|
105
|
+
subject,
|
|
106
|
+
value,
|
|
107
|
+
statement,
|
|
108
|
+
scope,
|
|
109
|
+
status: "proposed",
|
|
110
|
+
sources: [source],
|
|
111
|
+
overrides: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function discoverProject(root, contract) {
|
|
116
|
+
const sources = [];
|
|
117
|
+
const items = [];
|
|
118
|
+
const addFileSource = async (relative) => {
|
|
119
|
+
const id = generatedId("source", relative);
|
|
120
|
+
if (!sources.some((source) => source.id === id)) {
|
|
121
|
+
sources.push({ id, kind: "file", path: relative, digest: await digestPath(root, relative) });
|
|
122
|
+
}
|
|
123
|
+
return id;
|
|
124
|
+
};
|
|
125
|
+
const addPathSource = async (relative) => {
|
|
126
|
+
const id = generatedId("source-path", relative);
|
|
127
|
+
if (!sources.some((source) => source.id === id)) {
|
|
128
|
+
sources.push({ id, kind: "path", path: relative, digest: await digestPathIdentity(root, relative) });
|
|
129
|
+
}
|
|
130
|
+
return id;
|
|
131
|
+
};
|
|
132
|
+
const packageBytes = await fileOrNull(path.join(root, "package.json"));
|
|
133
|
+
if (packageBytes) {
|
|
134
|
+
const source = await addFileSource("package.json");
|
|
135
|
+
let packageJson;
|
|
136
|
+
try {
|
|
137
|
+
packageJson = JSON.parse(packageBytes.toString("utf8"));
|
|
138
|
+
} catch {
|
|
139
|
+
packageJson = null;
|
|
140
|
+
}
|
|
141
|
+
if (packageJson) {
|
|
142
|
+
if (typeof packageJson.packageManager === "string") {
|
|
143
|
+
items.push(proposalItem({
|
|
144
|
+
id: "fact-package-manager",
|
|
145
|
+
subject: "project.package-manager",
|
|
146
|
+
value: packageJson.packageManager,
|
|
147
|
+
statement: `The project declares package manager ${packageJson.packageManager}.`,
|
|
148
|
+
source,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
for (const name of Object.keys(packageJson.scripts ?? {}).sort()) {
|
|
152
|
+
const scriptId = generatedId("script", name);
|
|
153
|
+
items.push(proposalItem({
|
|
154
|
+
id: `fact-${scriptId}`,
|
|
155
|
+
subject: `project.${scriptId.replaceAll("-", ".")}`,
|
|
156
|
+
value: packageJson.scripts[name],
|
|
157
|
+
statement: `The project declares the ${name} script.`,
|
|
158
|
+
source,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
const dependencies = { ...(packageJson.dependencies ?? {}), ...(packageJson.devDependencies ?? {}) };
|
|
162
|
+
for (const name of TOOL_DEPENDENCIES) {
|
|
163
|
+
if (!(name in dependencies)) continue;
|
|
164
|
+
const dependencyId = generatedId("dependency", name);
|
|
165
|
+
items.push(proposalItem({
|
|
166
|
+
id: `fact-${dependencyId}`,
|
|
167
|
+
subject: `project.${dependencyId.replaceAll("-", ".")}`,
|
|
168
|
+
value: dependencies[name],
|
|
169
|
+
statement: `The project declares ${name} as a dependency.`,
|
|
170
|
+
source,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const relative of CONFIG_FILES) {
|
|
176
|
+
if (!(await fileOrNull(path.join(root, relative)))) continue;
|
|
177
|
+
const source = await addFileSource(relative);
|
|
178
|
+
const configId = generatedId("config", relative);
|
|
179
|
+
items.push(proposalItem({
|
|
180
|
+
id: `fact-${configId}`,
|
|
181
|
+
subject: `project.${configId.replaceAll("-", ".")}`,
|
|
182
|
+
value: true,
|
|
183
|
+
statement: `The project contains ${relative}.`,
|
|
184
|
+
source,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
for (const relative of await discoverTopLevelDirectories(root)) {
|
|
188
|
+
const source = await addPathSource(relative);
|
|
189
|
+
items.push(proposalItem({
|
|
190
|
+
id: `fact-${generatedId("top-level-directory", relative)}`,
|
|
191
|
+
subject: "project.structure.top-level-directory",
|
|
192
|
+
value: relative,
|
|
193
|
+
statement: `The project contains the top-level directory ${relative}.`,
|
|
194
|
+
source,
|
|
195
|
+
scope: { kind: "path-prefix", path: relative },
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
const rulePaths = await discoverRuleFiles(root);
|
|
199
|
+
const ruleContents = new Map();
|
|
200
|
+
for (const relative of rulePaths) {
|
|
201
|
+
const contents = await readFile(path.join(root, relative), "utf8");
|
|
202
|
+
if (!contents.startsWith("<!-- managed-by: project-context;")) ruleContents.set(relative, contents);
|
|
203
|
+
}
|
|
204
|
+
const knownRulePaths = new Set(ruleContents.keys());
|
|
205
|
+
const aliasTargets = new Map();
|
|
206
|
+
for (const [relative, contents] of ruleContents) {
|
|
207
|
+
const target = ruleAliasTarget(relative, contents);
|
|
208
|
+
if (target) aliasTargets.set(relative, target);
|
|
209
|
+
}
|
|
210
|
+
const redundantAliases = aliasesWithConcreteTargets(aliasTargets, knownRulePaths);
|
|
211
|
+
for (const [relative] of ruleContents) {
|
|
212
|
+
if (redundantAliases.has(relative)) continue;
|
|
213
|
+
const source = await addFileSource(relative);
|
|
214
|
+
items.push(proposalItem({
|
|
215
|
+
id: generatedId("reference-agent-rules", relative),
|
|
216
|
+
kind: "reference",
|
|
217
|
+
subject: `agent-rules.${slug(relative)}`,
|
|
218
|
+
value: relative,
|
|
219
|
+
statement: `Review the existing Agent instruction source at ${relative}.`,
|
|
220
|
+
source,
|
|
221
|
+
scope: relative.includes("/") && !relative.startsWith(".")
|
|
222
|
+
? { kind: "path-prefix", path: path.posix.dirname(relative) }
|
|
223
|
+
: { kind: "project" },
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
for (const source of contract.sources) {
|
|
227
|
+
if (sourceStatus(source) === "deprecated") continue;
|
|
228
|
+
if (contract.items.some((entry) => entry.sources.includes(source.id))) continue;
|
|
229
|
+
if (source.kind === "human-decision") continue;
|
|
230
|
+
try {
|
|
231
|
+
const registration = sourceRegistrationShape(source);
|
|
232
|
+
const current = source.kind === "external-reference"
|
|
233
|
+
? registration
|
|
234
|
+
: { ...registration, digest: await readSourceDigest(root, source) };
|
|
235
|
+
if (!sources.some((entry) => entry.id === current.id)) sources.push(current);
|
|
236
|
+
items.push(proposalItem({
|
|
237
|
+
id: generatedId("reference-registered", source.id),
|
|
238
|
+
kind: "reference",
|
|
239
|
+
subject: `registered-source.${source.id.replaceAll("-", ".")}`,
|
|
240
|
+
value: source.path ?? source.reference,
|
|
241
|
+
statement: `Review the explicitly registered project source ${source.id}.`,
|
|
242
|
+
source: source.id,
|
|
243
|
+
}));
|
|
244
|
+
} catch {
|
|
245
|
+
// Existing contract drift is reported by check; discovery remains deterministic and best-effort.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
sources.sort((left, right) => left.id.localeCompare(right.id));
|
|
249
|
+
items.sort((left, right) => left.id.localeCompare(right.id));
|
|
250
|
+
return { schemaVersion: 1, projectId: contract.project.id, sources, items };
|
|
251
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class ProjectContextError extends Error {
|
|
2
|
+
constructor(code, message, options = {}) {
|
|
3
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
4
|
+
this.name = "ProjectContextError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.exitCode = options.exitCode ?? 2;
|
|
7
|
+
this.details = options.details ?? {};
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fail(code, message, options) {
|
|
12
|
+
throw new ProjectContextError(code, message, options);
|
|
13
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { link, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { prettyCanonicalJson } from "./canonical-json.mjs";
|
|
5
|
+
import { ProjectContextError, fail } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
export async function readJsonFile(filePath, label = path.basename(filePath)) {
|
|
8
|
+
let source;
|
|
9
|
+
try {
|
|
10
|
+
source = await readFile(filePath, "utf8");
|
|
11
|
+
} catch (error) {
|
|
12
|
+
fail("file-read-failed", `cannot read ${label}`, { cause: error, details: { path: filePath } });
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(source);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
fail("json-invalid", `${label} contains invalid JSON`, { cause: error, details: { path: filePath } });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function atomicWriteFile(filePath, content) {
|
|
22
|
+
const directory = path.dirname(filePath);
|
|
23
|
+
const temporary = path.join(
|
|
24
|
+
directory,
|
|
25
|
+
`.${path.basename(filePath)}.project-context-${process.pid}-${randomBytes(6).toString("hex")}.tmp`,
|
|
26
|
+
);
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
29
|
+
await rename(temporary, filePath);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
try {
|
|
32
|
+
await unlink(temporary);
|
|
33
|
+
} catch {
|
|
34
|
+
// The temporary file may not have been created or may already have been renamed.
|
|
35
|
+
}
|
|
36
|
+
if (error instanceof ProjectContextError) throw error;
|
|
37
|
+
fail("atomic-write-failed", `failed to atomically write ${filePath}`, {
|
|
38
|
+
cause: error,
|
|
39
|
+
exitCode: 2,
|
|
40
|
+
details: { path: filePath },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function atomicWriteJson(filePath, value) {
|
|
46
|
+
await atomicWriteFile(filePath, prettyCanonicalJson(value));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function atomicCreateFileOrSame(filePath, content) {
|
|
50
|
+
try {
|
|
51
|
+
const existing = await readFile(filePath, "utf8");
|
|
52
|
+
if (existing === content) return "unchanged";
|
|
53
|
+
fail("proposal-output-conflict", `refusing to overwrite existing proposal output: ${filePath}`, {
|
|
54
|
+
details: { path: filePath },
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof ProjectContextError) throw error;
|
|
58
|
+
if (error?.code !== "ENOENT") {
|
|
59
|
+
fail("file-read-failed", `cannot inspect proposal output: ${filePath}`, { cause: error, details: { path: filePath } });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const directory = path.dirname(filePath);
|
|
64
|
+
const temporary = path.join(
|
|
65
|
+
directory,
|
|
66
|
+
`.${path.basename(filePath)}.project-context-${process.pid}-${randomBytes(6).toString("hex")}.tmp`,
|
|
67
|
+
);
|
|
68
|
+
try {
|
|
69
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
70
|
+
await link(temporary, filePath);
|
|
71
|
+
await unlink(temporary);
|
|
72
|
+
return "create";
|
|
73
|
+
} catch (error) {
|
|
74
|
+
try {
|
|
75
|
+
await unlink(temporary);
|
|
76
|
+
} catch {
|
|
77
|
+
// The temporary file may not have been created or may already have been removed.
|
|
78
|
+
}
|
|
79
|
+
if (error?.code === "EEXIST") {
|
|
80
|
+
const existing = await readFile(filePath, "utf8");
|
|
81
|
+
if (existing === content) return "unchanged";
|
|
82
|
+
fail("proposal-output-conflict", `refusing to overwrite existing proposal output: ${filePath}`, {
|
|
83
|
+
details: { path: filePath },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (error instanceof ProjectContextError) throw error;
|
|
87
|
+
fail("atomic-write-failed", `failed to atomically create ${filePath}`, {
|
|
88
|
+
cause: error,
|
|
89
|
+
exitCode: 2,
|
|
90
|
+
details: { path: filePath },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|